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