Backpropagation do zero: primeiro o motor, depois a rede
Escreva um motor autodiff em Python puro, compare com PyTorch até 16 casas decimais e entenda zero_grad ao removê-lo.
Nesta página
Quatro capítulos depois, há um buraco no meio do curso.
O Capítulo 3 nos deu gradient descent: para melhorar um parâmetro, encontre a inclinação da perda em relação a ele e dê um passo ladeira abaixo. O Capítulo 4 nos deu uma perda que vale a pena descer. Mas, em ambos, a derivada foi calculada à mão — um modelo, um parâmetro, uma linha de cálculo, e tudo cabia em uma página.
Agora empilhe duas camadas. A saída da primeira alimenta a segunda, então cada peso na primeira afeta a perda por meio de cada neurônio na segunda. Uma rede com duas camadas ocultas de cem unidades cada tem cerca de vinte mil parâmetros, e cada um precisa de sua própria derivada parcial da mesma perda. Fazer isso à mão não é tedioso; é impossível, e continua impossível para toda arquitetura no restante deste curso.
A saída não é uma notação melhor. É perceber que a derivada de uma composição pode ser calculada mecanicamente, por um programa, a partir da estrutura da própria computação — e que, se você fizer isso na direção certa, obtém todas as vinte mil derivadas por mais ou menos o custo de calcular a perda uma vez.
Esse mecanismo é diferenciação automática em modo reverso. Aplicado a uma rede neural, ele se chama backpropagation, e até o fim deste capítulo você terá escrito um em cerca de 120 linhas de Python, sem bibliotecas, comparado com o PyTorch, e usado para resolver o problema XOR que matou o perceptron no Capítulo 1.
Primeiro: por que precisa haver uma não linearidade
Link para a seção: Primeiro: por que precisa haver uma não linearidadeAntes de construir a máquina, uma pergunta precisa ser resolvida, porque, se a resposta fosse a outra, não haveria nada a construir.
O perceptron falhou no XOR porque uma linha não consegue separar os quatro pontos. A correção óbvia é empilhar: passar a entrada por uma camada linear, depois por outra. Isso ajuda?
Não, e a prova tem duas linhas. Uma camada linear é . Alimente outra com ela, , e substitua:
A composição é com e . Uma pilha de camadas lineares é uma única camada linear. Dez delas, mil delas: ainda uma linha, ainda incapaz de resolver XOR.
Vale a pena ver isso acontecer em vez de apenas acreditar:
import numpy as np
rng = np.random.default_rng(0)
W1, b1 = rng.normal(size=(3, 2)), rng.normal(size=3)
W2, b2 = rng.normal(size=(1, 3)), rng.normal(size=1)
x = rng.normal(size=2)
two_layers = W2 @ (W1 @ x + b1) + b2
one_layer = (W2 @ W1) @ x + (W2 @ b1 + b2)
print(two_layers[0], one_layer[0], abs(two_layers[0] - one_layer[0]))-4.612963371048 -4.612963371048 0.00e+00Não aproximadamente iguais. Idênticas bit a bit, porque é a mesma aritmética rearranjada.
Então profundidade, por si só, não compra nada. O que compra algo é colocar uma função não linear entre as camadas — e essa é toda a razão pela qual funções de ativação existem. Elas não são um floreio biológico nem um truque de normalização. Sem uma delas, a segunda camada é decoração.
A regra da cadeia, no papel, com um nó compartilhado
Link para a seção: A regra da cadeia, no papel, com um nó compartilhadoAgora a matemática, e é uma regra que você já conhece aplicada em um lugar um pouco menos familiar.
A regra da cadeia de uma variável diz que, se depende de e depende de , então . Derivadas se multiplicam ao longo de uma cadeia.
A parte que importa aqui é o que acontece quando uma variável alimenta mais de um caminho downstream. Se influencia por meio de e também por meio de , as contribuições se somam:
Multiplique ao longo de um caminho, some entre caminhos. Isso é a totalidade da backpropagation, e todo detalhe de implementação no restante deste capítulo — incluindo o += no código e a chamada zero_grad() que derruba todo mundo que escreve seu primeiro loop de treino — é uma consequência direta dessa segunda palavra.
Pegue um circuito concreto de cinco operações, com e :
Observe que aparece três vezes: em , em e diretamente em . Faça a passagem backward no papel, da direita para a esquerda, começando por :
Pela adição
Link para a seção: Pela adição, então e o caminho direto contribui . A adição distribui o gradiente de entrada inalterado para as duas entradas.
Pela tanh
Link para a seção: Pela tanhcom , então .
Pela multiplicação
Link para a seção: Pela multiplicação, então e . A multiplicação troca: o gradiente de cada entrada é escalado pela outra entrada.
Colete os três caminhos em x
Link para a seção: Colete os três caminhos em xPor : . Por : . Diretamente: .
Guarde esse número. Em algumas páginas, um programa vai produzi-lo sem que ninguém tenha contado nada disso a ele.
Construindo o motor
Link para a seção: Construindo o motorO insight que torna isso programável: cada uma daquelas etapas era local. Para empurrar um gradiente pelo nó de multiplicação, você precisava do gradiente de entrada e dos dois valores de entrada armazenados — nada sobre o restante do circuito. Cada operação sabe como diferenciar a si mesma.
Então crie um número que se lembra do que o produziu.
class Value:
"""A number that remembers where it came from."""
def __init__(self, data, _children=(), _op=""):
self.data = data
self.grad = 0.0
self._backward = lambda: None
self._prev = set(_children)
self._op = _opQuatro campos. data é o valor. grad acumula . _prev é o conjunto de Values a partir dos quais este foi calculado — as arestas do grafo. E _backward é uma closure que cada operação instala: ela sabe como empurrar o gradiente deste nó um passo para trás, até suas entradas.
Todo operador segue o mesmo formato: calcula a saída, registra os pais, instala a regra local.
def __add__(self, other):
other = other if isinstance(other, Value) else Value(other)
out = Value(self.data + other.data, (self, other), "+")
def _backward():
self.grad += out.grad
other.grad += out.grad
out._backward = _backward
return out
def __mul__(self, other):
other = other if isinstance(other, Value) else Value(other)
out = Value(self.data * other.data, (self, other), "*")
def _backward():
self.grad += other.data * out.grad
other.grad += self.data * out.grad
out._backward = _backward
return out
def tanh(self):
t = math.tanh(self.data)
out = Value(t, (self,), "tanh")
def _backward():
self.grad += (1 - t * t) * out.grad
out._backward = _backward
return out
def relu(self):
out = Value(self.data if self.data > 0 else 0.0, (self,), "relu")
def _backward():
self.grad += (1.0 if out.data > 0 else 0.0) * out.grad
out._backward = _backward
return outLeia os quatro corpos de _backward como uma tabela e os padrões de fluxo da derivação no papel aparecem logo ali:
| operação | o que ela faz com o gradiente |
|---|---|
+ | distribui — o mesmo gradiente para todas as entradas |
* | troca — cada entrada escalada pelo valor da outra |
relu | roteia — passa adiante ou bloqueia por completo |
tanh | atenua — escala por , que é no máximo 1 e normalmente menor |
Todas usam += e nunca =. Essa é a regra de “somar entre caminhos”, codificada. Um nó que alimenta dois consumidores é chamado duas vezes, e as duas contribuições se somam por conta própria.
Depois vem o driver, que é a única parte com qualquer conhecimento global:
def backward(self):
order, seen = [], set()
def build(v):
if v in seen:
return
seen.add(v)
for child in v._prev:
build(child)
order.append(v)
build(self)
self.grad = 1.0
for v in reversed(order):
v._backward() build produz uma ordenação topológica do grafo: todo nó aparece depois de todas as suas entradas. Percorrer essa lista ao contrário garante que, quando você chama o _backward de um nó, seu próprio gradiente já está completo — todo consumidor downstream dele já contribuiu. Erre a ordem e você empurra um gradiente meio pronto para trás, o que produz uma resposta errada sem mensagem de erro.
Isso concorda com o papel?
Link para a seção: Isso concorda com o papel?x = Value(0.5)
y = Value(1.4)
a = x * y
b = x + y
c = a * b
d = c.tanh()
L = d + x
L.backward()
print(x.grad, y.grad)forward: a=0.7000 b=1.9000 c=1.3300 d=0.8692 L=1.3692
backward: dL/dd=1.0000 dL/dc=0.2444 dL/da=0.4644 dL/db=0.1711
dL/dx=1.8212 dL/dy=0.40331.8212. O mesmo número, vindo de um programa que recebeu a regra para +, a regra para *, a regra para tanh, e nada sobre este circuito.
Duas verificações independentes, porque “bate com o que eu derivei” é um teste fraco quando a mesma pessoa fez as duas coisas.
Diferenciação numérica. Dê um pequeno empurrão na entrada e meça. A diferença centrada estima a derivada sem cálculo nenhum:
dL/dx: analytic=1.821202805 numeric=1.821202805 |diff|=1.80e-10
dL/dy: analytic=0.403269235 numeric=0.403269235 |diff|=7.64e-12Contra o PyTorch, que tem um motor autodiff industrial escrito por pessoas que fazem isso profissionalmente:
torch dL/dx=1.821202805316 ours=1.821202805316 |diff|=2.22e-16
torch dL/dy=0.403269234753 ours=0.403269234753 |diff|=1.11e-16Concordância em , que é o épsilon de máquina para um float de 64 bits: os dois motores estão realizando aritmética idêntica. Guarde a verificação numérica no bolso — ela é a ferramenta para depurar a passagem backward de uma nova camada, e é a razão pela qual um gradiente errado pode ser encontrado.
Saturação, medida
Link para a seção: Saturação, medidaO mesmo circuito, entradas diferentes. Defina e , o que torna :
x=0.5, y=1.4: dL/dc = 0.244400 three paths into x: 0.6501 + 0.1711 + 1.0000 = 1.8212
x=2.0, y=-3.0: dL/dc = 0.000025 three paths into x: 0.0001 + -0.0001 + 1.0000 = 0.9999O gradiente atravessando o nó caiu por um fator de 9.945. Tudo upstream dele — em uma rede real, toda camada antes dele — recebe praticamente nada. Os dois caminhos pelo circuito ficaram em silêncio; só a conexão direta que pula ainda carrega sinal.
Esse é o problema do gradiente desvanecente, em um nó. Empilhe quarenta camadas de e multiplique quarenta fatores desse tipo, e as camadas iniciais param de aprender por completo. Também é, incidentalmente, um argumento a favor de conexões de salto que você consegue ver aqui em miniatura: o caminho que contornou a não linearidade é o único que sobreviveu.
O que zero_grad realmente faz, e por que o bug se esconde
Link para a seção: O que zero_grad realmente faz, e por que o bug se escondeTodo _backward usa +=. Isso está correto — é assim que os caminhos se somam. Mas tem uma consequência que pega todo mundo: gradientes acumulam entre chamadas a backward() também. O motor não faz ideia de que sua segunda chamada é uma nova etapa de treino, e não outro caminho no mesmo grafo.
Então um loop de treino precisa limpá-los:
for step in range(steps):
ys = [model(x) for x, _ in DATA]
loss = sum((yp - yt) ** 2 for yp, (_, yt) in zip(ys, DATA))
for p in model.parameters():
p.grad = 0.0
loss.backward()
for p in model.parameters():
p.data -= lr * p.gradIsso é optimizer.zero_grad() no PyTorch, e o conselho usual é que esquecê-lo quebra o treino. Então vamos apagar essas duas linhas e ver o quanto fica quebrado. Mesmas seeds, mesmo tudo, 200 etapas de XOR:
| learning rate | seed | com reset | sem reset |
|---|---|---|---|
| 0.05 | 1337 | perda 3.255088, 3/4 | perda 0.000000, 4/4 |
| 0.05 | 7 | perda 2.144820, 2/4 | perda 0.000000, 4/4 |
| 0.05 | 42 | perda 2.126074, 2/4 | perda 0.000000, 4/4 |
| 0.1 | 1337 | perda 0.038597, 4/4 | perda 0.000000, 4/4 |
| 0.1 | 7 | perda 2.055048, 2/4 | perda 0.000000, 4/4 |
| 0.1 | 42 | perda 2.049876, 2/4 | perda 0.000073, 4/4 |
| 0.3 | 1337 | perda 4.512310, 2/4 | perda 8.000000, 2/4 |
| 0.3 | 7 | perda 0.015247, 4/4 | perda 4.000000, 3/4 |
| 0.3 | 42 | perda 0.005478, 4/4 | perda 4.000000, 3/4 |
Nas learning rates pequenas, a versão com bug vence em todas as linhas. Ela converge quando a versão correta empaca.
Isso não é acaso e vale a pena entender, porque explica por que esse bug é tão difícil de pegar. Se você nunca limpa o gradiente, então na etapa o parâmetro é atualizado pela soma de todos os gradientes calculados até agora. Em uma perda que continua apontando mais ou menos para a mesma direção, essa soma cresce de forma constante, e o efeito é uma learning rate que aumenta sozinha. Em , onde o algoritmo correto está se arrastando, o tamanho de passo descontrolado parece exatamente uma correção.
Depois olhe para as três linhas de baixo. Em , o mesmo mecanismo explode o modelo — perda 8.0 é o que um modelo colapsado para uma constante marca — metade dos 16 que quatro respostas maximamente erradas custariam — enquanto a versão correta agora converge limpamente.
Então a afirmação honesta não é “sempre chame zero_grad ou seu modelo não vai treinar”. É: sem isso, você não está mais executando gradient descent. Você está executando algo cujo tamanho de passo deriva para cima a uma taxa que ninguém escolheu, e vai parecer funcionar, às vezes melhor do que a coisa real, até deixar de funcionar — momento em que você vai culpar a learning rate, a inicialização ou os dados. Esse é o formato dos piores bugs em machine learning: eles não travam, eles transformam o algoritmo em outro algoritmo que ocasionalmente pontua melhor.
A rede, e finalmente o XOR
Link para a seção: A rede, e finalmente o XORCom o motor pronto, uma rede neural quase não exige código. Um neurônio é um produto escalar, um bias e uma ativação; uma camada é uma lista de neurônios; uma rede é uma lista de camadas.
class Neuron:
def __init__(self, nin):
self.w = [Value(random.uniform(-1, 1)) for _ in range(nin)]
self.b = Value(0.0)
def __call__(self, x):
act = sum((wi * xi for wi, xi in zip(self.w, x)), self.b)
return act.tanh()
def parameters(self):
return self.w + [self.b]
class Layer:
def __init__(self, nin, nout):
self.neurons = [Neuron(nin) for _ in range(nout)]
def __call__(self, x):
out = [n(x) for n in self.neurons]
return out[0] if len(out) == 1 else out
def parameters(self):
return [p for n in self.neurons for p in n.parameters()]
class MLP:
def __init__(self, nin, nouts):
sizes = [nin] + nouts
self.layers = [Layer(sizes[i], sizes[i + 1]) for i in range(len(nouts))]
def __call__(self, x):
for layer in self.layers:
x = layer(x)
return x
def parameters(self):
return [p for layer in self.layers for p in layer.parameters()]Não há passagem backward em nada disso. Nem uma linha. A classe Value já sabe diferenciar qualquer coisa que essas classes venham a construir, que é o objetivo de tê-la escrito primeiro: um motor autodiff não sabe que está sendo usado para uma rede neural.
Agora o problema do Capítulo 1. Duas entradas, duas unidades ocultas, uma saída, nove parâmetros:
step 1: loss 4.156690
step 10: loss 4.005572
step 50: loss 3.996708
step 100: loss 3.510700
step 200: loss 0.038597
[0, 0] -> -0.9081 (target -1) ok
[0, 1] -> +0.8934 (target +1) ok
[1, 0] -> +0.8906 (target +1) ok
[1, 1] -> -0.9207 (target -1) okQuatro de quatro. A função que nenhum perceptron consegue calcular — provada no Capítulo 1 por quatro desigualdades que exigiam que fosse positivo e negativo ao mesmo tempo — é calculada por nove números encontrados automaticamente.
O que a camada oculta fez
Link para a seção: O que a camada oculta fezA parte satisfatória não é funcionar. É conseguir ver como, porque, com duas unidades ocultas, a representação intermediária é um ponto em um plano e você pode simplesmente imprimi-la.
Treinada até uma perda de 0.001241, aqui está onde cada entrada cai depois da camada oculta, e o que o neurônio de saída faz com ela:
| entrada | saída da camada oculta | score de saída | rótulo |
|---|---|---|---|
Olhe para a primeira e a quarta linhas. As entradas e são cantos diagonalmente opostos do quadrado — tão distantes quanto dois pontos podem estar neste problema — e a camada oculta as mapeia para e . Quase o mesmo ponto. A camada dobrou o plano para que os dois cantos rejeitados caiam um sobre o outro, e, quando eles estão no mesmo lugar, uma linha os separa dos outros dois.
E o neurônio de saída é exatamente essa linha. Seus parâmetros aprendidos são , , então sua fronteira de decisão é
que é uma linha reta — um perceptron, o mesmo objeto do Capítulo 1, inalterado. Ele não conseguia resolver XOR antes e não consegue agora. O que mudou é que ele não está mais olhando para a entrada; está olhando para um espaço que a primeira camada construiu para ele, no qual o problema é linearmente separável.
Isso é o que uma representação aprendida é, e vale a pena ser preciso porque a expressão é usada de forma solta pelo restante deste curso e pelo restante da área. Não é uma compressão, um resumo ou um embedding em qualquer sentido místico. É uma mudança de coordenadas, aprendida em vez de projetada, cujo único trabalho é facilitar o trabalho da próxima camada.
O teorema da aproximação universal, e o que ele não diz
Link para a seção: O teorema da aproximação universal, e o que ele não dizHá um teorema aqui, e ele costuma ser mal citado.
Cybenko, em 1989, e Hornik, em 1991, provaram que uma rede feedforward com uma única camada oculta e uma função de ativação adequada pode aproximar qualquer função contínua em um conjunto compacto, com qualquer precisão que você quiser, desde que tenha unidades ocultas suficientes.34 É um resultado real e importante: ele diz que a arquitetura não é a limitação.
Agora leia o que ele omite. Ele não diz quantas unidades — o limite pode ser astronomicamente grande. Ele não diz que os pesos podem ser encontrados; ele afirma existência, e gradient descent a partir de um início aleatório não é um oráculo. E não diz nada sobre comportamento em dados que você não viu, que é a segunda metade do Capítulo 6.
A distância entre “existe” e “é encontrável” não é acadêmica. Aqui está o mesmo problema XOR, 50 inicializações aleatórias cada, 1000 etapas, mudando apenas o tamanho da camada oculta:
| unidades ocultas | inicializações que chegaram a 4/4 |
|---|---|
| 2 | 38 / 50 (76 %) |
| 3 | 49 / 50 (98 %) |
| 4 | 50 / 50 (100 %) |
| 8 | 47 / 50 (94 %) |
Com a arquitetura mínima viável, uma em cada quatro execuções nunca chega lá — ela se acomoda em uma configuração da qual não consegue descer, exatamente o mínimo local que o Capítulo 3 mostrou em uma superfície unidimensional. Adicione uma unidade e as falhas quase desaparecem, não porque a rede tenha ficado mais expressiva (duas unidades já bastam — 38 execuções provam isso), mas porque dimensões extras dão à descida mais direções pelas quais escapar.
E então oito unidades vai ligeiramente pior do que quatro. Com uma learning rate e um orçamento de etapas fixos, mais capacidade não é monotonicamente melhor. Quem disser que a correção para uma rede empacada é sempre uma rede maior está extrapolando a partir do meio dessa tabela.
Esta é a mesma lição do teorema de convergência no Capítulo 1, e será a mesma lição no Capítulo 10 sobre leis de escala, na forma que esse capítulo dá a ela: uma previsão da perda não é uma previsão da capacidade pela qual você está pagando, e a distância entre as duas é onde vive a engenharia.
Mostrar detalhes
Opcional: a forma matricial, e por que o código acima não a usa.
Tudo aqui foi escrito um escalar por vez, que é a forma mais clara de ver o mecanismo e a forma mais lenta de executá-lo. Na prática, uma camada é uma multiplicação de matrizes, e a passagem backward de é
As transpostas não são um truque para memorizar; elas são a aparência da regra de somar entre caminhos quando os caminhos são indexados por entradas de matriz. O objeto geral é o Jacobiano, a matriz de todas as derivadas parciais de todas as saídas em relação a todas as entradas, e o modo reverso é precisamente o cálculo de um produto vetor-Jacobiano sem jamais formar o Jacobiano — o que importa, porque, para uma camada com 4096 entradas e 4096 saídas, essa matriz tem dezesseis milhões de entradas e nunca vale a pena construí-la.
Você não precisa de nada disso para acompanhar os próximos capítulos; a versão escalar faz tudo o que a versão matricial faz, mais devagar. Isso se torna necessário no Capítulo 9, onde os formatos deixam de ser óbvios.
Para onde vamos agora
Link para a seção: Para onde vamos agoraAgora você tem uma rede que treina. Isso é uma conquista menor do que parece, porque a rede que você tem treina em quatro exemplos e é medida nos mesmos quatro.
Rode o mesmo código em um dataset real e surge um novo conjunto de problemas, nenhum deles sobre gradientes. A perda cai por um tempo e depois para. Ou cai nos dados de treino e sobe em todo o resto. Ou não se mexe desde a primeira etapa, e a causa acaba sendo a faixa dos pesos aleatórios iniciais. Ou a entrada de uma unidade ficou negativa em todos os exemplos na terceira época e ela está morta desde então, em silêncio, levando consigo um pedaço da capacidade do modelo.
Essas não são falhas exóticas; são a condição normal de uma rede que acabou de ser escrita, e nenhuma delas se anuncia. O gradiente está correto — você o comparou com o PyTorch até a décima sexta casa decimal — e o modelo ainda assim não aprende.
O Capítulo 6 trata disso: inicialização, normalização, overfitting e regularização, e o hábito diagnóstico de perguntar qual dessas coisas está acontecendo antes de mudar qualquer coisa. É a diferença entre uma rede que roda e uma rede que funciona.
Fontes e método
Link para a seção: Fontes e métodoA classe Value deste capítulo descende diretamente do micrograd de Andrej Karpathy, e o vídeo dele The spelled-out intro to neural networks and backpropagation: building micrograd é as melhores três horas que você pode gastar neste material se quiser vê-lo explicado de uma segunda forma por outra pessoa. O post dele de 2016, Yes you should understand backprop, defende o argumento de escrever um por conta própria e é leitura obrigatória no CS224n de Stanford. As notas do CS231n sobre backpropagation (cs231n.github.io/optimization-2) são o tratamento canônico dos padrões de fluxo tabulados acima. Para a matemática como cálculo em um grafo, e não como folclore de redes neurais, o capítulo 5.6 de Mathematics for Machine Learning, de Deisenroth, Faisal e Ong, é incomumente claro; e a revisão de Baydin, Pearlmutter, Radul e Siskind, Automatic Differentiation in Machine Learning: a Survey (arXiv:1502.05767), é a referência da área como um todo, incluindo a troca entre forward e reverso discutida acima.
Referências
Link para a seção: Referências-
Linnainmaa, S. The representation of the cumulative rounding error of an algorithm as a Taylor expansion of the local rounding errors. Dissertação de mestrado, Universidade de Helsinki (1970). Acumulação em modo reverso, dezesseis anos antes de chegar a este campo e sob uma motivação completamente diferente. ↩
-
Rumelhart, D. E., Hinton, G. E. e Williams, R. J. Learning representations by back-propagating errors. Nature 323, pp. 533–536 (1986). O artigo que tornou o método conhecido, e a fonte da leitura das unidades ocultas como representações aprendidas em que a seção O que a camada oculta fez deste capítulo concentra suas medições. ↩
-
Cybenko, G. Approximation by superpositions of a sigmoidal function. Mathematics of Control, Signals and Systems 2, pp. 303–314 (1989). ↩
-
Hornik, K. Approximation capabilities of multilayer feedforward networks. Neural Networks 4(2), pp. 251–257 (1991). Generaliza Cybenko: o resultado depende de a ativação ser não polinomial, não de ser sigmoidal. ↩