← início
Nível 2 · model/MlpModel.java

MLP

Aqui aparece o gradiente, e aparece sem autodiff: cada derivada é escrita explicitamente, para que você veja de onde ela vem antes de deixar uma biblioteca calculá-la por você.

Contexto: 8 caracteresTreino: poucos segundosPerda esperada: abaixo do bigrama
$ java -jar target/mini-gpt-java.jar train --model mlp

O nível por dentro

A limitação que este nível resolve

O bigrama esquece tudo, exceto um caractere. A correção óbvia seria contar trigramas, tetragramas, e assim por diante, mas a tabela cresce como V^k. Com V = 96 e uma janela de oito, seriam 96⁸ linhas, cerca de sete mil trilhões. E, pior do que o tamanho: a maioria delas ficaria em zero, porque nenhum corpus contém todas as combinações. A tabela gigante não teria nada a dizer justamente sobre as janelas que você mais precisa prever.

A saída é uma função, e não uma tabela maiorEm vez de tabelar todas as janelas, aprendemos uma função com poucos milhares de parâmetros que mapeia qualquer janela para uma distribuição. Janelas parecidas produzem saídas parecidas, o que a tabela nunca conseguiria, porque para ela "o menin" e "a menin" são duas linhas sem relação nenhuma.

A arquitetura

O caminho de ida do MLP: janela de 8 ids até a distribuição do próximo caracterejanela de T = 8 caracteresomeninoidsC[id] — embedding aprendidoXcat(B × T·E) = (B × 192)· W₁ + b₁, depois tanha(B × H) = (B × 128)· W₂ + b₂logits(B × V)softmaxpsoma 1 em cada linha
O caminho de ida, do id ao logit. Cada seta corresponde a uma linha de MlpModel.java.
x     =  concat( C[id₁], C[id₂], …, C[id_T] )        (T·E)
a     =  tanh( x · W₁ + b₁ )                          (H)
logits =  a · W₂ + b₂                                  (V)
L     =  entropia cruzada( softmax(logits), alvo )
C
a tabela de embeddings, V × E. A linha C[id] é o vetor que representa aquele caractere, e o treino a aprende em vez de recebê-la pronta
T
o comprimento da janela: 8 por padrão (--context)
E
a dimensão do embedding: 24 por padrão (--embed)
H
o tamanho da camada oculta: 128 por padrão (--hidden)
tanh
a não linearidade. Sem ela, duas camadas lineares colapsam numa só, e a rede inteira vira uma regressão linear

É a arquitetura de Bengio (2003), a mesma que popularizou a ideia de embeddings aprendidos. Repare que a concatenação é o ponto fraco: cada posição da janela recebe o seu próprio bloco de pesos em W₁, então o que a rede aprende sobre a posição 3 não vale nada para a posição 4. Guarde isso: é exatamente o que a atenção vai consertar no nível 3.

O gradiente que cai do céu

Derivar a entropia cruzada e o softmax separadamente é trabalhoso e numericamente instável. Derivar os dois juntos faz tudo cancelar e sobra uma linha, a única fórmula deste nível que vale a pena decorar:

∂L / ∂logits   =   ( P  −  onehot(y) )  /  B
P
as probabilidades previstas, softmax(logits), com uma linha por exemplo do lote
onehot(y)
um vetor com 1 na posição do caractere correto e 0 em todas as outras
B
o tamanho do mini-lote; dividir por ele é o que faz a perda ser uma média e não uma soma

Leia em português: o gradiente é o erro. "Quanta probabilidade eu dei" menos "quanta eu deveria ter dado". Se o modelo deu 0,7 ao caractere certo, aquela coordenada recebe 0,7 − 1 = −0,3 e o passo de treino aumenta aquele logit. Todos os outros recebem o próprio P, positivo, e são empurrados para baixo.

A cadeia inteira, na ordem em que ela roda

A partir de dLogits, cada passo seguinte é uma aplicação mecânica da regra da cadeia, andando de trás para frente pelo caminho de ida. Este bloco é o comentário de classe de MlpModel, copiado, e cada linha dele existe, comentada, no corpo de forwardBackward().

dLogits = (P − onehot(y)) / B (B×V)
dW2 = Aᵀ · dLogits db2 = Σ_b dLogits (H×V), (1×V)
dA = dLogits · W2ᵀ (B×H)
dH1 = dA ⊙ (1 − A²) (derivada da tanh) (B×H)
dW1 = Xcatᵀ · dH1 db1 = Σ_b dH1 (T·E×H), (1×H)
dXcat = dH1 · W1ᵀ (B×T·E)
dC[id] += fatia de dXcat daquela posição (scatter-add)
model/MlpModel.java — a derivação, na ordem do backward
Um truque para conferir sem fazer contaO gradiente de um tensor tem sempre a mesma forma que o tensor. Se W₁ é (T·E × H), então dW1 também é. Isso sozinho já determina de que lado cada transposta entra na multiplicação, e elimina a maior parte dos erros de derivação antes mesmo de você derivar.

A derivada da tanh, e por que ela some

a = tanh(z)        ⇒        da/dz = 1 − a²
a
a ativação que o forward já calculou: a derivada não precisa de z, só do resultado
1 − a²
vale 1 quando a = 0 e cai a zero quando a → ±1

Aí está a saturação: um neurônio empurrado para ±1 tem derivada quase nula, e o gradiente que passa por ele desaparece. Ele para de aprender sem dar nenhum sinal. É por isso que W₁ é inicializado com desvio 1/√fan_in: para as ativações começarem na região central, onde a derivada ainda é grande.

O scatter-add nos embeddings

O último passo do backward é o mais fácil de errar. Cada exemplo usou T linhas da tabela C, e o mesmo caractere pode aparecer várias vezes na mesma janela. Pense em "casa da ", com três a. O gradiente de cada uso tem que ser somado na linha correspondente, nunca atribuído.

for (int i = 0; i < b; i++) {
for (int t = 0; t < blockSize; t++) {
int id = windows[i][t];
int base = t * embedDim;
for (int e = 0; e < embedDim; e++) {
c.grad[id][e] += dXcat[i][base + e]; // +=, nunca =
}
}
}
model/MlpModel.java — o gradiente volta para as linhas usadas
Um '=' no lugar de um '+=' treina em silêncioTrocar o acúmulo por atribuição faz o modelo aprender com apenas um dos usos de cada caractere. A perda ainda cai, o programa não reclama, e o resultado é só um pouco pior: o tipo de defeito que se descobre semanas depois. É a razão de o MlpGradCheckTest existir.

Como sabemos que está certo

Cada uma das derivações acima é conferida contra a definição de derivada, por diferença central, com erro abaixo de 1e-5. O teste perturba um parâmetro de cada vez, mede a perda dos dois lados e compara com o que o backward afirmou. Nenhum gradiente deste nível está no repositório sem ter passado por isso.

mvn test -Dtest=MlpGradCheckTest
o teste que sustenta o nível inteiro

O que esperar do texto

  • A perda cai claramente abaixo da do bigrama, o primeiro sinal concreto de que a complexidade se pagou.
  • Sílabas legítimas aparecem, e depois palavras curtas inteiras: o modelo aprendeu que q é seguido de u, que nh existe e hn não.
  • A frase ainda não fecha: com oito caracteres de janela, não há como manter concordância nem lembrar o sujeito.
  • O treino leva segundos, o que faz deste o nível certo para experimentar hiperparâmetro.

Para fazer com as mãos

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.

E1Estique a janela

Rode com --context 4, 8 e 16, mantendo o resto igual. A perda melhora sempre? A partir de que ponto o ganho não paga o custo, e por que a concatenação torna esse custo linear em T?

E2Comprima o embedding

Use --embed 2. Cada caractere vira um ponto no plano. Imprima a tabela C ao fim do treino: vogais caem perto de vogais? Onde ficam o espaço e a quebra de linha?

E3Tire a não linearidade

Substitua Math.tanh(...) pela identidade e ajuste o backward (a derivada vira 1). A rede continua treinando, mas prove que ela deixou de ser mais expressiva que uma única camada linear.

E4Quebre um gradiente de propósito

Troque c.grad[id][e] += por = e rode mvn test. Qual teste falha, e o que exatamente ele reporta? Depois desfaça e repare que a perda de treino também caía com o bug.

O arquivo deste nívelTudo o que esta página explica está em model/MlpModel.java. O Javadoc da classe traz a mesma matemática, ao lado da linha que a implementa.