GPT em Python do ZERO

Resolvi fazer uma experiência que parece simples, mas é daquelas que limpam a mente: um GPT funcionando do zero, treinando e gerando texto, em Python, sem PyTorch, sem NumPy, sem “mágica” escondida. Só o modelo mínimo, as contas na mão, e um dataset em pt-BR com acentos de verdade.

O resultado é o esperado para um toy-model char-level: o “português” nasce meio torto, com sílabas quase encaixando, morfologia tentando virar frase, e ruído no meio. E isso é perfeito. Porque mostra exatamente o que um GPT pequeno consegue aprender e o que ele não consegue, sem maquiagem.

E sim: rodou de verdade. No final eu mostro os prints: loss descendo + amostras geradas. É a prova mais honesta possível.

Parte 1 — Dataset pt-BR (o que alimenta o modelo)

Antes do GPT, vem o mundo: texto. Só que aqui tem um detalhe que muda tudo em pt-BR: eu quero Unicode limpo, com acento “real”, e não uma sopa de bytes disfarçada.

O make_dataset_ptbr.py faz duas coisas bem objetivas: gera frases curtas (uma por linha) e normaliza/limpa para ficar consistente. O objetivo não é “qual o dataset mais bonito do planeta”. O objetivo é: formato bem comportado, repetição com variação controlada e poucos caracteres esquisitos. Isso encaixa perfeitamente num modelo mínimo com contexto curto.

O trecho mais importante do script é a normalização em NFC. Por quê? Porque “á” pode existir como um único caractere ou como “a” + acento combinante. Se você não normaliza, você cria um vocabulário fantasma. E aí o modelo passa tempo aprendendo diferenças invisíveis entre letras que parecem iguais.

python

# make_dataset_ptbr.py
from __future__ import annotations

import random
import re
import unicodedata
from pathlib import Path

def normalize_ptbr(s: str) -> str:
    s = s.strip()
    s = unicodedata.normalize("NFC", s)
    s = s.replace("—", "-").replace("“", '"').replace("”", '"').replace("…", "...")
    s = re.sub(r"\s+", " ", s)
    s = re.sub(r"[ ]+([,.!?;:])", r"\1", s)
    s = re.sub(r"\.{4,}", "...", s)
    s = re.sub(r"([!?]){3,}", r"\1\1", s)
    return s

Depois disso, eu gero linhas com templates que forçam conectivos a aparecerem com frequência (porque toy-model precisa ver padrão muitas vezes). Eu também filtro: sem números, sem duplicata, tamanho controlado. A ideia é reduzir ruído e aumentar sinal.

E um detalhe prático que te salva de dor de cabeça no Windows: eu salvo o input.txt sempre na mesma pasta do script, usando Path(__file__). Assim você não depende do “diretório atual” do terminal.

python

def write_dataset(out_path: Path, lines: list[str]) -> None:
    out_path.parent.mkdir(parents=True, exist_ok=True)
    with out_path.open("w", encoding="utf-8", newline="\n") as f:
        for s in lines:
            f.write(s + "\n")

def main() -> None:
    seed = 42
    n = 85000

    base_dir = Path(__file__).resolve().parent
    out_path = base_dir / "input.txt"

    lines = build_lines(seed=seed, n=n)
    write_dataset(out_path, lines)

    print(f"Gerado {out_path} com {len(lines)} linhas (UTF-8).")

Parte 2 — Tokenização char-level (Unicode de verdade)

Eu usei tokenização por caractere. Não porque é “melhor”. Porque é a forma mais didática de ver um GPT aprendendo padrão. Você literalmente assiste o modelo aprender “Quando ”, “a gente ”, “o modelo ”, e travar onde começa a exigir sintaxe longa.

A regra é simples: coleto todos os caracteres que aparecem no dataset, ordeno, e crio o vocabulário. E aqui entra uma escolha que ajuda muito a leitura das amostras: separar <BOS> e <EOS>. Início e fim são coisas diferentes. Se você usa um token só pra tudo, o modelo aprende a “encerrar cedo” e você nem percebe, porque você dá break e pronto.

python

def build_char_vocab(docs):
    charset = set()
    for d in docs:
        for ch in d:
            charset.add(ch)

    itos = sorted(list(charset))
    bos_id = len(itos)
    eos_id = len(itos) + 1
    itos.append("<BOS>")
    itos.append("<EOS>")
    stoi = {ch: i for i, ch in enumerate(itos)}
    return stoi, itos, bos_id, eos_id

def encode(doc, stoi, bos_id, eos_id):
    return [bos_id] + [stoi[ch] for ch in doc] + [eos_id]

Parte 3 — O GPT mínimo (estrutura e por quê)

Aqui eu fiz uma escolha bem consciente: em vez de um arquivo monolítico, eu organizei em peças que dão pra ler.

Config: hiperparâmetros e treino. Param: pesos + grad + Adam. TinyGPT: forward/backward + KV-cache. Treinador: loop de treino + schedule + clipping.

A matemática é a mesma base do transformer: embeddings → RMSNorm → atenção causal → residual → RMSNorm → MLP → residual → head.

Duas decisões “didáticas” importantes:

Eu uso ReLU no MLP. Não porque é o estado da arte, mas porque é fácil de entender de primeira. Eu uso warmup + cosine decay no learning rate. Toy-model em CPU adora instabilidade no começo. Isso reduz a chance de “quebrar do nada”.

Parte 4 — Adam e parâmetros (a diferença entre “rodar” e “treinar”)

Um monte de implementação “from scratch” roda mas não aprende direito porque o otimizador fica implícito ou mal feito. Então eu deixei o Adam explícito no próprio parâmetro.

python

class Param:
    def __init__(self, size, init_std, rng):
        self.w = [rng.gauss(0.0, init_std) for _ in range(size)]
        self.g = [0.0] * size
        self.m = [0.0] * size
        self.v = [0.0] * size

    def adam_step(self, lr, b1, b2, eps, step):
        b1c = 1.0 - (b1 ** (step + 1))
        b2c = 1.0 - (b2 ** (step + 1))

        for i in range(len(self.w)):
            gi = self.g[i]
            self.m[i] = b1 * self.m[i] + (1.0 - b1) * gi
            self.v[i] = b2 * self.v[i] + (1.0 - b2) * (gi * gi)

            mhat = self.m[i] / b1c
            vhat = self.v[i] / b2c
            self.w[i] -= lr * mhat / (math.sqrt(vhat) + eps)

            self.g[i] = 0.0

Sabe esse:

python

self.w[i] -= lr * mhat / (math.sqrt(vhat) + eps)

Pois é… é raiz quadrada. E você jurando que nunca ia usar na vida.

Repara no final: eu zero o grad ali. Isso dá o comportamento clássico: forward/backward acumulam gradiente; o update aplica e limpa.

Parte 5 — RMSNorm, Linear e Softmax (as três peças que aparecem o tempo todo)

O GPT mínimo é basicamente repetição dessas três ideias. Linear (matmul), RMSNorm (estabiliza), Softmax (probabilidades). E eu deixo isso literal, porque aqui o objetivo é entendimento, não performance.

python

def linear_fwd(x, w, nout, nin):
    out = [0.0] * nout
    for r in range(nout):
        base = r * nin
        s = 0.0
        for c in range(nin):
            s += w[base + c] * x[c]
        out[r] = s
    return out

def rmsnorm_fwd(x, eps=1e-5):
    ms = sum(v*v for v in x) / len(x)
    scale = 1.0 / math.sqrt(ms + eps)
    return [v * scale for v in x], scale

def softmax(logits):
    mx = max(logits)
    exps = [math.exp(z - mx) for z in logits]
    s = sum(exps)
    return [e / s for e in exps]

O “subtrai max” no softmax é obrigatório. Sem isso, você toma overflow e o treino vira lixo silencioso.

Parte 6 — Atenção causal com KV-cache (o coração do GPT)

Esse é o ponto que muita gente evita porque parece difícil, mas a ideia é simples:

Projeta Q, K, V. Q consulta todos os K anteriores (causal). Softmax vira pesos. Saída vira uma média ponderada dos V.

Eu guardo K e V por posição (KV cache). Isso torna o modelo autoregressivo e mais eficiente. E aqui tem uma verdade importante: o que parece “complicado” é indexação. A matemática é dot product e soma ponderada.

O miolo do forward da atenção é isso:

python

# k e v ficam guardados por posição
self.kv_k[pos] = k[:]
self.kv_v[pos] = v[:]

seq_len = pos + 1
scale = 1.0 / math.sqrt(head_dim)

for h in range(cfg.n_head):
    hs = h * head_dim
    logits = [0.0] * seq_len

    for t in range(seq_len):
        dot = 0.0
        kt = self.kv_k[t]
        for j in range(head_dim):
            dot += q[hs + j] * kt[hs + j]
        logits[t] = dot * scale

    probs = softmax(logits)
    # saída = soma_t probs[t] * V[t]

Parte 7 — Backprop manual (onde a verdade aparece)

Vou ser direto: backprop manual é o lugar onde você descobre se entende ou se só decorou.

O que eu faço é salvar ativações por posição (Acts) e no backward caminhar do fim para o começo. Na atenção, o detalhe que muda o jogo é o acoplamento temporal: mexer em um token afeta os outros via softmax, então você precisa de acumuladores (dk_acc, dv_acc) por posição.

Eu não vou colar o backward inteiro aqui porque fica enorme e mata o artigo. Mas o núcleo conceitual é isso:

dL/dlogits = p – one_hot volta pelo head volta pelo MLP com residual volta pela atenção (softmax backward + dot backward) volta pelo RMSNorm e embeddings

No código completo, isso está explícito no backward_sequence().

Parte 8 — Treino: warmup, cosine e clipping

Treinar toy-model em CPU tem duas armadilhas clássicas: instabilidade no começo e gradiente explodindo do nada. Eu resolvi do jeito clássico: warmup + cosine decay e clipping por norma global.

PS: Quando eu coloquei cos() no código eu ouvi, um aluno perguntando: ‘professor onde eu vou usar seno e cosseno?’.

python

def lr_schedule(cfg, step):
    if step < cfg.warmup_steps:
        return cfg.lr * (step + 1) / cfg.warmup_steps

    t = (step - cfg.warmup_steps) / max(1, (cfg.steps - cfg.warmup_steps))
    cosine = 0.5 * (1.0 + math.cos(math.pi * t))
    return cfg.lr_min + (cfg.lr - cfg.lr_min) * cosine

Isso não “melhora” a inteligência. Isso melhora a chance do treino não quebrar.

Parte 9 — Inferência: temperatura e top-k (sem maquiagem, mas com controle)

Aqui tem uma sutileza importante: top-k deixa o texto mais legível, mas também pode enganar você, porque ele está podando a cauda de probabilidades. Por isso eu deixo top-k opcional.

Quer ver a verdade crua? top_k=None. Quer ver samples publicáveis? top_k=20.

python

token = sample_from_logits(
    logits,
    rng=rng,
    temperature=cfg.temperature,
    top_k=cfg.top_k
)
if token == EOS:
    break

Parte 10 — O que significa o log do treino (a leitura correta)

Quando você vê algo assim:

step 300/2000 | loss 2.4268 | lr 0.000973

Você está vendo, numa linha, três coisas: em que ponto do treino você está, o quão errado o modelo está, e quão forte foi o update.

step 300/2000 é a iteração. Em cada step, você pega um exemplo (ou mini-batch), roda forward, calcula loss, roda backward e atualiza pesos.

loss 2.4268 é o erro médio (quase sempre cross-entropy por token). Quanto menor, melhor. Dá pra transformar em intuição: perplexity = exp(loss). Em char-level isso cai e ainda assim o texto pode parecer ruim, porque o modelo está acertando padrões locais, não sintaxe longa.

lr 0.000973 é o learning rate daquele step (no meu caso, schedule com warmup+cosine).

Parte 11 — “Rodou?” Rodou.

Depois do treino vem o modo geração:

— inference —

E aí você vê samples assim:

sample 01: o sistem sample 02: a gente sample 12: Quando a sample 20: o dado t

Por que isso acontece?

Porque toy-model char-level colapsa fácil para prefixos muito prováveis. Se seu dataset tem muitos começos parecidos (“o sistema…”, “a gente…”, “o modelo…”), ele aprende isso rápido e fica preso nesses inícios.

Porque block_size pequeno corta o fôlego. Se você dá 8, 16 ou 32 passos, você literalmente limita o tamanho máximo. Aí aparecem cortes no meio (“o sistem”, “o dado t”). Isso não é “bug”. É limite físico do contexto.

E porque agora começou a aparecer “Quando a”, o que é um sinal bom. Significa que o modelo internalizou um início plausível da distribuição do seu dataset. Só que ele ainda não tem capacidade, dados e janela para completar uma oração inteira com conectivo + consequência.

Parte 12 — Próximos passos (se você quer melhorar de verdade)

Se você quer que conectivos apareçam no meio e não só no começo, você precisa treinar o modelo a ver “miolo”, não só prefixo. A forma mais simples é recortar janelas aleatórias dentro da linha (random start). Isso muda a distribuição do treino.

E se você quer frases mais longas, não tem milagre: aumenta block_size, aumenta n_embd, e aceita o custo. Em listas Python, o custo cresce rápido.

Ahhhh sim claro, o código completo tá lá no meu GitHub

https://github.com/elzobrito/gpt-python

Deixe um comentário