Um modelo de linguagem em nível de caractere, do zero em Java puro. Sem PyTorch, sem TensorFlow, sem DJL: toda a álgebra linear é double[][] que cabe na tela. Treze arquivos, cerca de 3.200 linhas — e três níveis, do bigrama que só conta pares até um Transformer com atenção causal.
$ java -jar target/mini-gpt-java.jar train --model transformer --steps 2000== Treino: modelo 'transformer' ==Corpus: data/corpus.txt (1043271 caracteres, vocabulario V=96)Contexto=64 | Treino: 938943 tokens | Validacao: 104328 tokenspasso loss_treino loss_val tempo1 4.5762 4.5701 0.71s100 2.8410 2.8395 75.94s300 2.2137 2.2088 226.15s500 2.0431 2.0396 376.02s--- amostra (100 caracteres) ---o mento de sua parte de comprar a estava da porta, e o menta de casa de dia1000 1.8502 1.8571 751.38s--- amostra (100 caracteres) ---a noite chegava devagar, e o menino nao sabia mais o que dizia na porta1500 1.7410 1.7566 1126.77s--- amostra (100 caracteres) ---o velho abriu a loja com cuidado e olhou para a rua, como fazia todos os dias2000 1.6588 1.6902 1502.44s--- amostra (100 caracteres) ---as criancas corriam pela rua estreita e chamavam umas as outras pelos nomesVocabulario salvo em target/transformer.vocab
Repare no que acontece entre o passo 500 e o 2000: o que o modelo ganha é estatística. Primeiro sílabas, depois palavras, depois concordância. Nenhuma regra de português foi escrita em lugar nenhum: só a conta de prever o próximo caractere, repetida duas mil vezes. A sessão acima ilustra o formato que Trainer imprime e segue a meta do projeto (perda de validação abaixo de 1,7); os números da sua máquina dependem do seu corpus.
Ele responde a uma pergunta, repetidamente: dado o texto até aqui, qual é o próximo caractere? Contagem, gradiente e atenção são três maneiras cada vez melhores de responder a essa única pergunta.
p(x₁, x₂, …, x_T) = ∏ p(x_t | x₁ … x_{t−1})t — aqui, uma letra, um espaço ou uma quebra de linha, não uma palavraEssa igualdade é só a regra do produto da probabilidade, sem nenhuma hipótese escondida. O que muda de um nível para o outro é quanto do passado cada modelo consegue de fato usar: o bigrama usa um caractere, o MLP usa oito, e o Transformer usa sessenta e quatro, decidindo sozinho a quais deles prestar atenção.
Prever uma distribuição é apostar. A métrica precisa premiar quem deu probabilidade alta ao caractere que de fato veio, e punir quem deu quase nenhuma. A entropia cruzada faz exatamente isso, e é a mesma nos três níveis, o que permite compará-los.
L = − (1/N) Σ ln p(x_t real)
Leia assim: se o modelo dá probabilidade alta ao caractere certo, −ln p fica perto de zero. Se dá probabilidade quase nula, o valor explode. A perda é a surpresa média do modelo, medida em nats por caractere. Um modelo que chuta uniformemente entre V caracteres tem perda ln V: com V = 96, isso é 4,56. Todo nível deste projeto começa exatamente aí, e desce.
Cada degrau resolve uma limitação concreta do degrau anterior, e cada um tem que bater a perda do anterior para justificar a própria complexidade. É por isso que o projeto começa por um modelo que qualquer pessoa entende em cinco minutos.
O próximo caractere depende apenas do anterior. Estimamos a probabilidade contando quantas vezes cada par apareceu no corpus e dividindo. Não há gradiente, não há pesos, não há laço de treino.
Uma rede rasa que olha uma janela fixa de oito caracteres. Embedding, concatenação, camada linear, tanh, camada de saída. Todos os gradientes escritos explicitamente, na ordem da regra da cadeia.
Um GPT em miniatura: embeddings de token e de posição, self-attention causal multi-head, LayerNorm, conexões residuais, feed-forward e pesos amarrados. Aqui ninguém escreve gradiente: o grafo do Tensor faz isso.
As perdas abaixo pressupõem um corpus real de cerca de 1 MB em português. Com o placeholder curto que vem no repositório, os três níveis parecem melhores do que são: o modelo decora em vez de generalizar, e é isso que a perda de validação existe para denunciar.
São seis etapas, e nenhuma delas é mágica. Cada caixa abaixo é um arquivo do repositório que você pode abrir hoje.
data/corpus.txt
Um arquivo .txt em UTF-8. Cerca de 1 MB de português em domínio público é o alvo. Quanto mais consistente o texto, mais legível a saída.
data/CharTokenizer.java
Cada caractere distinto vira um número. Os caracteres são ordenados por code point, então o mesmo corpus gera sempre os mesmos ids. É uma bijeção, com decode(encode(t)) == t.
data/Dataset.java
O corpus linear vira pares (x, y): y é x deslocado de uma posição. Os primeiros 90% são treino, o resto é validação, cortados por posição e nunca embaralhados.
model/*.java
O nível que você escolheu transforma o contexto em logits: um número por caractere do vocabulário, ainda sem normalizar.
train/Trainer.java
Sorteia um mini-lote, roda forward e backward, e pede um passo ao AdamOptimizer. A cada 500 passos gera uma amostra, para você ver o texto evoluir.
generate/Sampler.java
Softmax nos logits, temperatura, top-k, sorteio. O caractere sorteado entra no contexto e o laço recomeça: é isso que quer dizer autoregressivo.
As quatro demonstrações abaixo são traduções fiéis do código Java do repositório, reescritas em TypeScript para rodarem aqui. Mexa nos controles: o objetivo é você sentir o que cada parâmetro faz antes de ler a fórmula.
Escreva qualquer coisa. Cada caractere distinto do corpus recebeu um id na ordem do code point Unicode, e é esse número, não a letra, que entra na conta. Repare que o espaço e a quebra de linha também são caracteres, e que ã é um id como qualquer outro.
stoi e itos fazem no Java. Nível de caractere é a escolha didática do projeto: o vocabulário fica pequeno (dezenas de ids em vez de dezenas de milhares) e o modelo tem que aprender a soletrar, o que torna o progresso visível a olho nu.Este botão faz o que fit() faz: uma passada pelo corpus contando pares. Depois gera texto sorteando da distribuição contada. É o modelo inteiro: no nível 1 não há mais nada. O seletor de corpus troca a linguagem que ele conta, e é aí que mora a lição.
Troque para a linguagem de ordem 1 e o mesmo modelo passa a acertar tudo. São doze palavras em que cada letra interna determina sozinha a sua sucessora, ou seja, uma linguagem em que a hipótese do bigrama é verdadeira em vez de aproximada. O contador de palavras inválidas mostra isso acontecendo. Baixe o α até 0,01 e ele chega a zero; suba para 1 e as quimeras voltam (mãsal, nóAzul), porque a suavização dá massa às transições que a linguagem proíbe. É a mesma α que existe para impedir que um par nunca visto mande a perda para o infinito: aqui ela protege a métrica e estraga o texto, e dá para ver os dois efeitos no mesmo controle.
Repare no que ela não acerta: a ordem das palavras. Depois de um espaço, a linha da tabela é a mesma qualquer que tenha sido a palavra anterior, então o espaço apaga todo o contexto. Nenhum ajuste de α, τ ou top-k recupera isso, e é por isso que doze palavras é perto do teto: cada letra interna gasta uma sucessora exclusiva do alfabeto.
A distribuição abaixo é real: são as probabilidades do próximo caractere depois de um a, contadas no mesmo corpus. Os dois controles não mudam o modelo; mudam apenas como se sorteia dele.
Os 12 caracteres mais prováveis depois de a, em ordem fixa. A barra mostra a probabilidade depois da temperatura e do top-k, normalizada pela maior.
τ → 0 a amostragem vira argmax e o texto passa a se repetir para sempre.Cada linha é uma posição da sequência; cada coluna é uma posição que ela pode olhar. Arraste o controle para escolher a posição que está sendo prevista e veja o que ela enxerga.
Ela pode olhar 6 posições: o ␣ m e n i. Tudo o que vem depois está a −∞ e sai do softmax com peso exatamente zero.
Repare na posição 0: ela só pode olhar para si mesma. É o caso em que a atenção não tem nada a somar, e é por isso que o começo de qualquer geração é o pedaço mais frágil do texto.
E repare que o triângulo cheio é exatamente o desenho do logotipo deste site: é a figura que o projeto inteiro existe para explicar.
−∞ antes do softmax, o que as leva a peso exatamente zero depois dele. Sem essa máscara, o modelo veria o caractere que deveria prever: a perda de treino despencaria, o modelo não aprenderia nada útil e nada no programa reclamaria.Um sinal de menos trocado numa derivada não quebra nada: o programa roda, a perda cai um pouco e o modelo fica ruim sem dizer por quê. É o tipo de defeito que consome semanas. Por isso todo gradiente deste projeto é conferido contra a definição de derivada.
∂f/∂θ ≈ ( f(θ + ε) − f(θ − ε) ) / (2ε)
1e-5A diferença central é lenta demais para treinar (uma perturbação por parâmetro, duas avaliações cada), mas é independente do backward que se quer testar. Se o gradiente analítico e o numérico batem até a quinta casa, o backward está certo. Se não batem, o teste falha e diz qual tensor.
MlpGradCheckTestConfere todos os gradientes explícitos do nível 2 (dC, dW1, db1, dW2, db2), com erro abaixo de 1e-5.
TransformerGradCheckTestConfere o grafo inteiro do nível 3: atenção, LayerNorm, residuais, feed-forward e a cabeça de pesos amarrados, todos pelo mesmo critério.
TensorGradCheckTestConfere mais de doze operações do autodiff isoladamente: matmul, softmaxRows, layerNorm, causalMask, crossEntropyRows e outras.
TrainingLearnsTestConfere a afirmação que importa no fim: que Trainer mais AdamOptimizer de fato reduzem a perda do MLP e do Transformer.
$ mvn testO repositório tem treze classes; estas dez são o caminho. Lidas nesta ordem, cada uma só usa o que a anterior já explicou, e nenhuma exige que você acredite em nada por enquanto.
A aposta deste projeto já deu certo antes, em outra área da computação.
Em 1987, Andrew Tanenbaum publicou Operating Systems: Design and Implementation com um sistema operacional inteiro junto — o MINIX — e o código-fonte completo impresso no apêndice do livro. A aposta era que um aluno aprende mais lendo um sistema operacional inteiro do que lendo sobre um. Quatro anos depois, Linus Torvalds escreveu a primeira versão do Linux numa máquina rodando MINIX e a anunciou no grupo comp.os.minix.
Modelos de linguagem estão hoje onde os sistemas operacionais estavam então: todo mundo usa, quase ninguém leu um por dentro. Os que estão em produção têm bilhões de parâmetros e dependem de bibliotecas grandes demais para alguém ler de ponta a ponta. Este tem treze arquivos e cerca de 3.200 linhas, nenhuma dependência além do JUnit, e treina e gera texto. Dá para ler inteiro num fim de semana.
A matemática vem de dois artigos. Bengio et al. (2003), A Neural Probabilistic Language Model, é a arquitetura do nível 2 e o artigo que o Javadoc de MlpModel cita; foi ele que popularizou a ideia de embeddings aprendidos. Vaswani et al. (2017), Attention Is All You Need, é o nível 3. Os três degraus seguem a ordem em que essas ideias apareceram: primeiro a contagem, depois a rede rasa, depois a atenção.
Cada restrição abaixo existe para que o código continue legível por quem está aprendendo.
Nada de DJL, ND4J, DL4J ou TensorFlow. Toda a álgebra linear é implementada aqui mesmo, com double[][], porque uma chamada de biblioteca é exatamente o ponto em que o aprendizado pararia.
JUnit 6, e mais nada. O pom.xml cabe numa tela, e mvn test roda numa máquina recém-formatada.
Cada classe traz, no Javadoc, a fórmula que ela implementa. O comentário e a linha de código ficam a centímetros um do outro, que é a única distância em que os dois se mantêm sincronizados.
O Tensor.matmul distribui as linhas do resultado entre os núcleos com um parallel stream. É o que mantém o Transformer dentro de meia hora sem recorrer a nada nativo.
Todo sorteio passa por um Random com semente explícita (--seed). Mesma semente, mesmo corpus, mesmo texto, o que torna um experimento comparável com o anterior.
Os modelos treináveis não persistem pesos: cada execução treina do zero. Custa tempo, e em troca não existe nenhum arquivo binário fazendo o trabalho que o código deveria mostrar.