Essencial

Construa um GPT do zero, com anotações

Um programa PyTorch completo transforma caracteres em tokens, treina um Transformer causal e gera texto.

Atualizada em

1

Conceito

A forma mais rápida de concretizar o Transformer é implementar um. O programa abaixo é pequeno, mas completo: cria um tokenizer de caracteres, amostra batches, implementa causal multi-head self-attention, empilha blocos residuais, minimiza next-token cross-entropy e gera texto. Ele usa PyTorch para tensores e diferenciação automática, mas nenhum módulo Transformer de alto nível.

Salve o bloco como minimal_gpt.py, instale PyTorch pelo comando indicado para sua plataforma em pytorch.org e execute python minimal_gpt.py. O treinamento roda em CPU numa experiência educacional curta. O corpus repetido e a rede pequena priorizam inspeção, não qualidade linguística. O programa imprime losses medidas; valores exatos podem variar conforme versão do PyTorch e hardware.

"""Um GPT completo e mínimo em caracteres. Execute: python minimal_gpt.py"""

from dataclasses import dataclass

import torch
import torch.nn as nn
from torch.nn import functional as F


torch.manual_seed(42)
device = torch.device("cpu")  # Baseline portátil; altere conscientemente se quiser.

# Corpus real incluído no arquivo para o exemplo ser autocontido.
text = ("attention routes information; residual streams preserve it.\n" * 200)
chars = sorted(set(text))
stoi = {character: index for index, character in enumerate(chars)}
itos = {index: character for character, index in stoi.items()}
data = torch.tensor([stoi[character] for character in text], dtype=torch.long)


@dataclass(frozen=True)
class Config:
    vocab_size: int = len(chars)
    block_size: int = 32
    batch_size: int = 16
    n_embed: int = 64
    n_head: int = 4
    n_layer: int = 2
    dropout: float = 0.0


config = Config()
split = int(0.9 * len(data))
train_data, validation_data = data[:split], data[split:]


def get_batch(source: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
    """Retorna prefixos x e seus targets next-token deslocados por uma posição."""
    starts = torch.randint(len(source) - config.block_size - 1, (config.batch_size,))
    x = torch.stack([source[i : i + config.block_size] for i in starts])
    y = torch.stack([source[i + 1 : i + config.block_size + 1] for i in starts])
    return x.to(device), y.to(device)


class AttentionHead(nn.Module):
    def __init__(self, head_size: int) -> None:
        super().__init__()
        self.key = nn.Linear(config.n_embed, head_size, bias=False)
        self.query = nn.Linear(config.n_embed, head_size, bias=False)
        self.value = nn.Linear(config.n_embed, head_size, bias=False)
        # O triângulo acompanha o modelo, mas não recebe gradientes.
        self.register_buffer("causal", torch.tril(torch.ones(config.block_size, config.block_size)))

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        _, time, _ = x.shape
        key, query, value = self.key(x), self.query(x), self.value(x)
        # (B,T,H) @ (B,H,T) -> (B,T,T): toda query pontua toda key.
        weights = query @ key.transpose(-2, -1) * (key.shape[-1] ** -0.5)
        weights = weights.masked_fill(self.causal[:time, :time] == 0, float("-inf"))
        weights = F.softmax(weights, dim=-1)
        return weights @ value  # (B,T,T) @ (B,T,H) -> (B,T,H)


class MultiHeadAttention(nn.Module):
    def __init__(self) -> None:
        super().__init__()
        head_size = config.n_embed // config.n_head
        self.heads = nn.ModuleList(
            [AttentionHead(head_size) for _ in range(config.n_head)]
        )
        self.output = nn.Linear(config.n_embed, config.n_embed)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        # Heads roteiam separadas; a concatenação recupera a largura do modelo.
        return self.output(torch.cat([head(x) for head in self.heads], dim=-1))


class FeedForward(nn.Module):
    def __init__(self) -> None:
        super().__init__()
        self.network = nn.Sequential(
            nn.Linear(config.n_embed, 4 * config.n_embed),
            nn.GELU(),
            nn.Linear(4 * config.n_embed, config.n_embed),
        )

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        return self.network(x)


class Block(nn.Module):
    def __init__(self) -> None:
        super().__init__()
        self.attention = MultiHeadAttention()
        self.feed_forward = FeedForward()
        self.norm_attention = nn.LayerNorm(config.n_embed)
        self.norm_ffn = nn.LayerNorm(config.n_embed)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        # Updates residuais pre-norm: primeiro comunicar, depois transformar.
        x = x + self.attention(self.norm_attention(x))
        x = x + self.feed_forward(self.norm_ffn(x))
        return x


class MiniGPT(nn.Module):
    def __init__(self) -> None:
        super().__init__()
        self.token_embedding = nn.Embedding(config.vocab_size, config.n_embed)
        self.position_embedding = nn.Embedding(config.block_size, config.n_embed)
        self.blocks = nn.Sequential(*[Block() for _ in range(config.n_layer)])
        self.final_norm = nn.LayerNorm(config.n_embed)
        self.language_head = nn.Linear(config.n_embed, config.vocab_size)

    def forward(
        self, indices: torch.Tensor, targets: torch.Tensor | None = None
    ) -> tuple[torch.Tensor, torch.Tensor | None]:
        _, time = indices.shape
        positions = torch.arange(time, device=indices.device)
        x = self.token_embedding(indices) + self.position_embedding(positions)
        x = self.blocks(x)
        logits = self.language_head(self.final_norm(x))  # (B,T,V)
        loss = None
        if targets is not None:
            loss = F.cross_entropy(
                logits.reshape(-1, config.vocab_size), targets.reshape(-1)
            )
        return logits, loss

    @torch.no_grad()
    def generate(self, indices: torch.Tensor, new_tokens: int) -> torch.Tensor:
        for _ in range(new_tokens):
            context = indices[:, -config.block_size :]
            logits, _ = self(context)
            probabilities = F.softmax(logits[:, -1, :], dim=-1)
            next_token = torch.multinomial(probabilities, num_samples=1)
            indices = torch.cat((indices, next_token), dim=1)
        return indices


model = MiniGPT().to(device)
optimizer = torch.optim.AdamW(model.parameters(), lr=3e-3)

for step in range(301):
    inputs, targets = get_batch(train_data)
    _, loss = model(inputs, targets)
    if loss is None:
        raise RuntimeError("training targets must produce a loss")
    optimizer.zero_grad(set_to_none=True)
    loss.backward()
    optimizer.step()
    if step % 100 == 0:
        print(f"step {step}: train loss {loss.item():.4f}")

seed = torch.zeros((1, 1), dtype=torch.long, device=device)
generated = model.generate(seed, new_tokens=160)[0].tolist()
print("".join(itos[index] for index in generated))

Leia o código seguindo shapes. indices é B×TB\times T. Consultas de token e posição produzem B×T×CB\times T\times C. Cada head projeta CC features para H=C/hH=C/h, forma uma matriz T×TT\times T por item, aplica o triângulo e mistura values. Concatenar hh heads recupera CC. A FFN expande para 4C4C e volta a CC. Toda soma residual tem, portanto, shape compatível.

O target é o input deslocado uma posição. Cross-entropy compara logits de vocabulário em cada posição com o próximo caractere real. Diferenciação automática percorre output head, blocos, pesos de attention e embeddings. AdamW atualiza parâmetros; a máscara causal e os dados permanecem fixos.

Geração usa o mesmo forward sem targets. Somente os logits da última posição preveem o próximo caractere. Sampling anexa um ID, e o corte respeita o limite da tabela posicional. Um GPT de produção adiciona attention fundida, dropout, inicialização melhor, grandes corpora tokenizados, treinamento distribuído, checkpoints, avaliações, mixed precision e KV cache. São camadas de escala e confiabilidade em torno do mesmo núcleo causal que agora pode ser inspecionado linha por linha.

2

Como explicar para uma criança de cinco anos

Monte uma prensa de mesa antes de visitar uma gráfica industrial. A versão pequena tem bandejas de tipos, duas estações de attention, uma prensa feed-forward e um optimizer movido à manivela. Ela não imprimirá um grande jornal, mas cada engrenagem se move pelo mesmo motivo da versão industrial. Como nada fica escondido numa biblioteca de modelos, você liga cada linha a uma equação do Transformer.

3

Ensine de volta

Percorra um batch no GPT mínimo, dos token IDs inteiros até a cross-entropy loss, e explique como a geração reutiliza o mesmo modelo.

Mínimo: 80 caracteres e 15 palavras. Seu texto fica somente neste navegador.

Salvo somente neste dispositivo.

Ver uma resposta-modelo

IDs consultam token e position embeddings, produzindo estados B×T×C. Cada bloco aplica multi-head causal attention normalizada e uma atualização feed-forward por conexões residuais. Norm final e projeção de vocabulário geram logits B×T×V. Achatar logits e targets deslocados produz next-token cross-entropy, cujos gradientes atualizam parâmetros. Na geração, o modelo corta o contexto ao limite, usa os logits da última posição, amostra um token, anexa-o e repete o mesmo forward.

4

Teste seu entendimento

1. Por que a máscara triangular é registrada como buffer?
Resposta e explicação

Ela acompanha o módulo entre devices, mas não é treinável — Buffers integram o estado e mudam de device com o módulo, sem receber gradientes nem updates do optimizer.

2. Quais logits amostram o próximo token na geração?
Resposta e explicação

Os logits da última posição do contexto — A última posição representa todo o prefixo disponível e prevê o token seguinte.

Conclua o teach-back e acerte o quiz para finalizar a aula.

Fontes

  1. Alec Radford et al. (2018). Improving Language Understanding by Generative Pre-Training.
  2. Andrej Karpathy (2022). nanoGPT.