O degrau final. Duas mudanças o separam do nível 2. O contexto deixa de ser uma janela rígida e passa a ser ponderado pelo próprio modelo; e os gradientes deixam de ser escritos um a um e passam a ser derivados por um grafo.
$ java -jar target/mini-gpt-java.jar train --model transformerO MLP concatena a janela. Isso tem duas consequências ruins. Primeira: cada posição ganha um bloco próprio de pesos, então o que a rede aprende na posição 3 não transfere para a posição 4. Segunda: a janela é rígida, com oito caracteres sempre, sejam eles relevantes ou não.
x, daí o nome self-attention.Q = x·W_Q K = x·W_K V = x·W_V atenção(Q, K, V) = softmax( Q·Kᵀ / √d + máscara ) · V
d = E / número de cabeçasO softmax por linha transforma essas afinidades em pesos que somam 1. A saída de uma posição é a média ponderada dos valores das posições que ela decidiu olhar. Mude o texto e os pesos mudam.
Multi-head é o mesmo mecanismo repetido em subespaços independentes. Com E = 128 e 4 cabeças, cada cabeça trabalha em d = 32 dimensões e pode se especializar (uma acompanha a palavra em curso, outra o começo da frase). As saídas são concatenadas de volta a 128.
O produto escalar de dois vetores aleatórios de dimensão d tem desvio padrão proporcional a √d. Sem correção, com d = 32 os escores chegam ao softmax grandes demais; ele satura, um peso vai a quase 1 e os outros a quase 0, e a derivada do softmax saturado é quase nula. O modelo para de aprender antes de começar.
1/√fan_in, e é por isso que aqui se divide por √d. Duas aparições da mesma preocupação.Nada no mecanismo acima impede a posição 5 de olhar a posição 9. E a posição 9 é justamente o caractere que a posição 5 deveria prever. Sem impedimento, o modelo aprende a copiar a resposta: a perda de treino despenca e o modelo generaliza zero.
escores[i][j] = −∞ sempre que j > i
e^{−∞} = 0: depois do softmax, o peso é exatamente zeroO Tensor.causalMask aplica isso antes do softmax, e não depois. Zerar depois exigiria renormalizar em seguida e ainda deixaria o gradiente fluir pelo caminho proibido: o mascaramento tem que acontecer onde o softmax possa vê-lo.
Embaralhe as posições de entrada e a atenção devolve as mesmas saídas, embaralhadas junto. Ela é uma operação sobre um conjunto, não sobre uma sequência, e uma sequência de caracteres embaralhada não é português. A correção é somar, a cada posição, um vetor que depende só do índice:
Tensor x = Tensor.add(Tensor.rows(tokEmb, tokIds), // quem é o caractereTensor.rows(posEmb, posIds)); // onde ele está
Os dois embeddings são aprendidos. A soma parece ingênua, e funciona porque o espaço tem 128 dimensões, com direções de sobra para carregar as duas informações sem que uma apague a outra.
--blocks).x ← x + atenção( LayerNorm(x) ) x ← x + feedForward( LayerNorm(x) )
γ e β aprendidosO residual é o que torna a profundidade viável. Como a derivada de x + f(x) em relação a x é 1 + f'(x), existe sempre um caminho por onde o gradiente chega intacto às camadas de baixo. Sem ele, o gradiente atravessa um produto de fatores e desaparece.
A cabeça de saída poderia ser mais uma matriz E × V. Em vez disso, o projeto reaproveita a tabela de embeddings transposta: logits = x · tokEmbᵀ. A mesma matriz que diz "este caractere é este vetor" responde "qual caractere se parece com este vetor?", e a economia é de V × E parâmetros, o que também regulariza.
Um Tensor guarda três coisas: o valor, o gradiente acumulado e uma referência às entradas que o produziram, junto com a função que propaga o gradiente para elas. Cada operação (matmul, layerNorm, softmaxRows, …) constrói um nó novo e registra esse caminho de volta. Ao final, loss.backwardAll() percorre o grafo em ordem topológica reversa, aplicando cada propagação uma vez.
dW2 = Aᵀ·dLogits passo a passo reconhece a mesma expressão dentro do backward do matmul. O autodiff é o nível 2 escrito uma vez por operação, em vez de uma vez por modelo.Quase tudo no Transformer age em cada posição independentemente: embeddings, LayerNorm, as projeções Q/K/V, o feed-forward e a cabeça de saída. Empilhando as B sequências de comprimento T numa única matriz (B·T, E), essas camadas viram uma multiplicação grande em vez de B pequenas, o que importa muito em CPU. Só a atenção mistura posições, e por isso só ela é feita sequência por sequência.
--steps leva mais fundo, ao custo de tempo.Nenhum destes exercícios pede uma biblioteca nova, e todos cabem numa sessão. O terceiro e o quarto de cada nível são os que mais ensinam, porque quebram alguma coisa de propósito.
Compare --heads 1 com --heads 4, mantendo --embed 128. O número de parâmetros é praticamente o mesmo, então qualquer diferença de perda vem da estrutura, não do tamanho.
Rode com --context 16 e com 64. Quanto o contexto longo vale, em nats? E quanto ele custa, em segundos por passo? (A atenção é quadrática em T.)
Remova a chamada a Tensor.causalMask e treine por 200 passos. A perda de treino despenca; a de validação, não. Explique o que o modelo aprendeu a fazer, e por que é inútil.
Rode com --blocks 3. Custa mais tempo por passo e melhora a perda? Depois pense: se as conexões residuais não existissem, o que aconteceria com o gradiente ao atravessar três blocos?
model/TransformerModel.java. O Javadoc da classe traz a mesma matemática, ao lado da linha que a implementa.