Backpropagation do zero: primeiro o motor, depois a rede
Escreva um motor autodiff em Python puro, compare-o com PyTorch a 16 casas decimais e perceba o zero_grad apagando-o.
Nesta página
Quatro capítulos depois, há um buraco no meio do curso.
O capítulo 3 deu-nos gradient descent: para melhorar um parâmetro, encontrar a inclinação da perda em relação a ele e dar um passo encosta abaixo. O capítulo 4 deu-nos 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 cabia numa página.
Agora empilhe duas camadas. A saída da primeira alimenta a segunda, por isso cada peso na primeira afeta a perda através 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 da sua própria derivada parcial da mesma perda. Fazer isso à mão não é aborrecido; é impossível, e continua impossível para todas as arquiteturas no resto 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 o fizer na direção certa, obtém todas as vinte mil derivadas por aproximadamente o custo de calcular a perda uma vez.
Esse mecanismo é diferenciação automática em modo inverso. Aplicado a uma rede neuronal chama-se backpropagation e, no fim deste capítulo, terá escrito uma em cerca de 120 linhas de Python sem bibliotecas, comparado o resultado com PyTorch e usado-a para resolver o problema XOR que matou o perceptron no capítulo 1.
Primeiro: porque tem de haver uma não linearidade
Ligação para a secção: Primeiro: porque tem de haver uma não linearidadeAntes de construir a máquina, é preciso resolver uma pergunta, porque se a resposta fosse a outra não haveria nada para 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 e 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: continua a ser uma linha, continua sem conseguir fazer 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 são aproximadamente iguais. São idênticas bit a bit, porque é a mesma aritmética rearranjada.
Portanto, a profundidade por si só não compra nada. O que compra alguma coisa é colocar uma função não linear entre as camadas — e essa é toda a razão pela qual existem funções de ativação. Não são um floreado biológico nem um truque de normalização. Sem uma, a segunda camada é decoração.
A regra da cadeia, no papel, com um nó partilhado
Ligação para a secção: A regra da cadeia, no papel, com um nó partilhadoAgora a matemática, e é uma regra que já conhece aplicada num sítio ligeiramente pouco familiar.
A regra da cadeia de uma variável diz que, se depende de e depende de , então . As derivadas multiplicam-se ao longo de uma cadeia.
A parte que interessa aqui é o que acontece quando uma variável alimenta mais do que um caminho a jusante. Se influencia através de e também através de , as contribuições somam-se:
Multiplicar ao longo de um caminho, somar entre caminhos. Isso é todo o backpropagation, e cada detalhe de implementação no resto deste capítulo — incluindo o += no código e a chamada zero_grad() que atrapalha todos os que escrevem o seu primeiro ciclo de treino — é uma consequência direta dessa segunda palavra.
Pegue num circuito concreto de cinco operações, com e :
Repare que aparece três vezes: em , em e diretamente em . Faça a passagem backward no papel, da direita para a esquerda, começando em :
Pela adição
Ligação para a secção: Pela adição, portanto e o caminho direto contribui . A adição distribui o gradiente recebido sem o alterar para ambas as entradas.
Pela tanh
Ligação para a secção: Pela tanhcom , portanto .
Pela multiplicação
Ligação para a secção: Pela multiplicação, portanto e . A multiplicação troca: o gradiente de cada entrada é escalado pela outra entrada.
Reunir os três caminhos em x
Ligação para a secção: Reunir os três caminhos em xAtravés de : . Através de : . Diretamente: .
Guarde esse número. Daqui a poucas páginas, um programa vai produzi-lo sem que lhe seja dito nada disto.
Construir o motor
Ligação para a secção: Construir o motorA ideia que torna isto programável: todos esses passos foram locais. Para empurrar um gradiente pelo nó de multiplicação, precisava do gradiente recebido e dos dois valores de entrada guardados — nada sobre o resto do circuito. Cada operação sabe como se diferenciar a si própria.
Portanto, 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: sabe como empurrar o gradiente deste nó um passo para trás, para as suas entradas.
Cada operador segue a mesma forma: calcular a saída, registar os pais, instalar 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 _backward como uma tabela e os padrões de fluxo da derivação no papel estão logo ali:
| operação | o que faz ao gradiente |
|---|---|
+ | distribui — o mesmo gradiente para cada entrada |
* | troca — cada entrada escalada pelo valor da outra |
relu | encaminha — deixa passar ou bloqueia por completo |
tanh | atenua — escala por , que é no máximo 1 e normalmente menor |
Cada uma usa += e nunca =. Essa é a regra de «somar entre caminhos», codificada. Um nó que alimenta dois consumidores é chamado duas vezes, e as duas contribuições somam-se por si.
Depois vem o controlador, que é a única parte com algum 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: cada nó aparece depois de todas as suas entradas. Percorrer essa lista ao contrário garante que, quando chama o _backward de um nó, o seu próprio gradiente já está completo — todos os consumidores a jusante já contribuíram. Se errar a ordem, empurra para trás um gradiente meio acabado, o que produz uma resposta errada sem mensagem de erro.
Concorda com o papel?
Ligação para a secção: 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 a que foi dita a regra para +, a regra para *, a regra para tanh, e nada sobre este circuito.
Duas verificações independentes, porque «bate certo com o que derivei» é um teste fraco quando a mesma pessoa fez as duas coisas.
Diferenciação numérica. Dê um pequeno empurrão à entrada e meça. A diferença centrada estima a derivada sem cálculo algum:
dL/dx: analytic=1.821202805 numeric=1.821202805 |diff|=1.80e-10
dL/dy: analytic=0.403269235 numeric=0.403269235 |diff|=7.64e-12Contra PyTorch, que tem um motor autodiff industrial escrito por pessoas que fazem isto 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 a realizar aritmética idêntica. Guarde a verificação numérica — é a ferramenta para depurar a passagem backward de uma nova camada, e é a razão pela qual um gradiente errado é encontrável.
Saturação, medida
Ligação para a secçã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 que atravessa o nó caiu por um fator de 9.945. Tudo a montante dele — numa rede real, todas as camadas antes dele — recebe praticamente nada. Os dois caminhos pelo circuito ficaram silenciosos; só a ligação direta que salta o ainda transporta sinal.
Esse é o problema do gradiente que desaparece, num único nó. Empilhe quarenta camadas de e multiplique quarenta fatores desses, e as camadas iniciais deixam de aprender por completo. É também, incidentalmente, um argumento a favor de ligações de salto que consegue ver aqui em miniatura: o caminho que contornou a não linearidade é o único que sobreviveu.
O que zero_grad faz de facto, e porque o bug se esconde
Ligação para a secção: O que zero_grad faz de facto, e porque o bug se escondeCada _backward usa +=. Isso está correto — é assim que os caminhos somam. Mas tem uma consequência que apanha toda a gente: os gradientes acumulam também entre chamadas a backward(). O motor não faz ideia de que a sua segunda chamada é um novo passo de treino em vez de outro caminho no mesmo grafo.
Por isso, um ciclo de treino tem de os limpar:
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.gradIsto é optimizer.zero_grad() em PyTorch, e o conselho habitual é que esquecê-lo estraga o treino. Portanto, vamos apagar essas duas linhas e ver quão estragado fica. Mesmas seeds, tudo igual, 200 passos de XOR:
| learning rate | seed | com reset | sem reset |
|---|---|---|---|
| 0.05 | 1337 | loss 3.255088, 3/4 | loss 0.000000, 4/4 |
| 0.05 | 7 | loss 2.144820, 2/4 | loss 0.000000, 4/4 |
| 0.05 | 42 | loss 2.126074, 2/4 | loss 0.000000, 4/4 |
| 0.1 | 1337 | loss 0.038597, 4/4 | loss 0.000000, 4/4 |
| 0.1 | 7 | loss 2.055048, 2/4 | loss 0.000000, 4/4 |
| 0.1 | 42 | loss 2.049876, 2/4 | loss 0.000073, 4/4 |
| 0.3 | 1337 | loss 4.512310, 2/4 | loss 8.000000, 2/4 |
| 0.3 | 7 | loss 0.015247, 4/4 | loss 4.000000, 3/4 |
| 0.3 | 42 | loss 0.005478, 4/4 | loss 4.000000, 3/4 |
Com learning rates pequenos, a versão com bug ganha em todas as linhas. Converge quando a versão correta fica bloqueada.
Isso não é acaso e vale a pena perceber, porque explica por que este bug é tão difícil de apanhar. Se nunca limpar o gradiente, então no passo o parâmetro é atualizado pela soma de todos os gradientes calculados até então. Numa perda que continua a apontar mais ou menos na mesma direção, essa soma cresce de forma constante, e o efeito é uma learning rate que aumenta sozinha. Em , onde o algoritmo correto se arrasta, o tamanho de passo descontrolado parece exatamente uma correção.
Depois olhe para as três linhas de baixo. Em , o mesmo mecanismo desfaz o modelo — perda 8.0 é o que obtém um modelo colapsado para uma constante — metade dos 16 que quatro respostas maximamente erradas custariam — enquanto a versão correta agora converge limpidamente.
Portanto, a afirmação honesta não é «chame sempre zero_grad ou o seu modelo não treina». É: sem isso, já não está a executar gradient descent. Está a executar 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é ao momento em que deixa de funcionar — e aí culpará a learning rate, a inicialização ou os dados. Esta é a forma dos piores bugs em machine learning: não rebentam, mudam o algoritmo para um algoritmo diferente que ocasionalmente pontua melhor.
A rede, e finalmente o XOR
Ligação para a secção: A rede, e finalmente o XORCom o motor terminado, uma rede neuronal quase não exige código. Um neurónio é um produto escalar, um viés 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 seja o que for que estas classes construam, que é o objetivo de a termos escrito primeiro: um motor autodiff não sabe que está a ser usado para uma rede neuronal.
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 em quatro. A função que nenhum perceptron consegue calcular — provado no capítulo 1 por quatro desigualdades que exigiam que fosse simultaneamente positivo e negativo — é calculada por nove números encontrados automaticamente.
O que a camada oculta fez
Ligação para a secção: O que a camada oculta fezA parte satisfatória não é funcionar. É conseguir ver como, porque com duas unidades ocultas a representação intermédia é um ponto num plano e pode simplesmente imprimi-la.
Treinada até uma perda de 0.001241, eis onde cada entrada aterra depois da camada oculta, e o que o neurónio de saída faz com ela:
| input | saída da camada oculta | pontuação de saída | label |
|---|---|---|---|
Olhe para a primeira e a quarta linhas. As entradas e são cantos diagonalmente opostos do quadrado — tão afastados quanto dois pontos neste problema conseguem estar — e a camada oculta mapeia-os para e . Quase o mesmo ponto. A camada dobrou o plano de modo que os dois cantos rejeitados aterram um em cima do outro e, depois de estarem no mesmo lugar, uma linha separa-os dos outros dois.
E o neurónio de saída é exatamente essa linha. Os seus parâmetros aprendidos são , , por isso a sua fronteira de decisão é
que é uma linha reta — um perceptron, o mesmo objeto do capítulo 1, inalterado. Não conseguia resolver XOR antes e não consegue agora. O que mudou é que já não está a olhar para a entrada; está a olhar para um espaço que a primeira camada construiu para ele, no qual o problema é linearmente separável.
Isso é uma representação aprendida, e vale a pena ser preciso porque a expressão é usada de forma solta no resto deste curso, e no resto da área. Não é uma compressão, um resumo nem um embedding em qualquer sentido místico. É uma mudança de coordenadas, aprendida em vez de desenhada, cujo único trabalho é tornar fácil o trabalho da camada seguinte.
O teorema da aproximação universal, e o que não diz
Ligação para a secção: O teorema da aproximação universal, e o que não dizHá aqui um teorema, e 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 consegue aproximar qualquer função contínua num conjunto compacto, com a precisão que quiser, desde que tenha unidades ocultas suficientes.34 É um resultado genuíno e importante: diz que a arquitetura não é a limitação.
Agora leia o que omite. Não diz quantas unidades — o limite pode ser astronomicamente grande. Não diz que os pesos podem ser encontrados; 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 o comportamento em dados que não viu, que é a segunda metade do capítulo 6.
A distância entre «existe» e «é encontrável» não é académica. Eis o mesmo problema XOR, 50 inicializações aleatórias cada, 1000 passos, mudando apenas o tamanho da camada oculta:
| unidades ocultas | inicializações que atingem 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 execução em quatro nunca lá chega — assenta numa configuração da qual não consegue descer, exatamente o mínimo local que o capítulo 3 mostrou numa superfície unidimensional. Acrescente uma unidade e as falhas quase desaparecem, não porque a rede se tornou mais expressiva (duas unidades já bastam — 38 execuções provam-no), mas porque dimensões extra dão à descida mais direções por onde escapar.
E depois oito unidades saem ligeiramente pior do que quatro. Com uma learning rate e um orçamento de passos fixos, mais capacidade não é monotonicamente melhor. Quem lhe disser que a correção para uma rede bloqueada é sempre uma rede maior está a extrapolar a partir do meio dessa tabela.
É a mesma lição do teorema da 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 lhe dá: uma previsão da perda não é uma previsão da capacidade pela qual está a pagar, e a distância entre as duas é onde vive a engenharia.
Mostrar detalhes
Opcional: a forma matricial, e porque o código acima não a usa.
Tudo aqui foi escrito um escalar de cada vez, que é a forma mais clara de ver o mecanismo e a mais lenta de o executar. 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; são o aspeto da regra de soma 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 inverso é precisamente o cálculo de um produto vetor-Jacobiano sem nunca formar o Jacobiano — o que importa, porque para uma camada com 4096 entradas e 4096 saídas essa matriz tem dezasseis milhões de entradas e nunca vale a pena construí-la.
Não precisa de nada disto para acompanhar os próximos capítulos; a versão escalar faz tudo o que a versão matricial faz, mais devagar. Torna-se necessária no capítulo 9, onde as formas deixam de ser óbvias.
Para onde isto segue
Ligação para a secção: Para onde isto segueAgora tem uma rede que treina. É uma conquista menor do que parece, porque a rede que tem treina em quatro exemplos e é medida nos mesmos quatro.
Execute o mesmo código num conjunto de dados real e aparece um novo conjunto de problemas, nenhum deles sobre gradientes. A perda desce durante algum tempo e depois para. Ou desce nos dados de treino e sobe em tudo o resto. Ou não se mexe desde o primeiro passo, e a causa acaba por ser o intervalo dos pesos aleatórios iniciais. Ou a entrada de uma unidade ficou negativa em todos os exemplos na época três e está morta desde então, silenciosamente, levando consigo uma parte da capacidade do modelo.
Estas não são falhas exóticas; são a condição normal de uma rede acabada de escrever, e nenhuma delas se anuncia. O gradiente está correto — comparou-o com PyTorch a dezasseis casas decimais — e o modelo continua sem aprender.
O capítulo 6 é sobre isso: inicialização, normalização, overfitting e regularização, e o hábito diagnóstico de perguntar qual destas coisas está a acontecer antes de mudar seja o que for. É a diferença entre uma rede que corre e uma rede que funciona.
Fontes e método
Ligação para a secção: Fontes e métodoA classe Value neste 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 pode gastar neste material se quiser vê-lo explicado de outra forma por outra pessoa. O seu artigo de 2016 Yes you should understand backprop defende o caso de escrever uma por si próprio e é leitura obrigatória no CS224n de Stanford. As notas de CS231n sobre backpropagation (cs231n.github.io/optimization-2) são o tratamento canónico dos padrões de fluxo tabelados acima. Para a matemática como cálculo num grafo, e não como folclore de redes neuronais, o capítulo 5.6 de Mathematics for Machine Learning de Deisenroth, Faisal e Ong é invulgarmente claro; e o estudo de Baydin, Pearlmutter, Radul e Siskind Automatic Differentiation in Machine Learning: a Survey (arXiv:1502.05767) é a referência para a área como um todo, incluindo o trade-off forward/inverso discutido acima.
Referências
Ligação para a secção: Referências-
Linnainmaa, S. The representation of the cumulative rounding error of an algorithm as a Taylor expansion of the local rounding errors. Tese de mestrado, Universidade de Helsínquia (1970). Acumulação em modo inverso, dezasseis anos antes de chegar a esta área e com uma motivação completamente diferente. ↩
-
Rumelhart, D. E., Hinton, G. E. and 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, que a secção O que a camada oculta fez deste capítulo examina com as 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. ↩