Backpropagation desde cero: primeiro o motor, despois a rede
Escribe un motor autodiff de 120 liñas en Python puro, compárao con PyTorch ata dezaseis decimais e aprende que fai zero_grad ao borralo.
Nesta páxina
Catro capítulos despois, hai un oco no medio do curso.
O Capítulo 3 deunos gradient descent: para mellorar un parámetro, atopa a pendente da perda con respecto a el e dá un paso costa abaixo. O Capítulo 4 deunos unha perda pola que paga a pena descender. Pero en ambos os casos, a derivada calculábase á man: un modelo, un parámetro, unha liña de cálculo, e cabía nunha páxina.
Agora amoreas dúas capas. A saída da primeira alimenta a segunda, así que cada peso da primeira afecta á perda a través de cada neurona da segunda. Unha rede con dúas capas ocultas de cen unidades cada unha ten arredor de vinte mil parámetros, e cada un necesita a súa propia derivada parcial da mesma perda. Facelo á man non é tedioso; é imposible, e segue sendo imposible para todas as arquitecturas do resto deste curso.
A saída non é unha notación mellor. É decatarse de que a derivada dunha composición pode calculala mecanicamente un programa a partir da estrutura da propia computación; e de que, se o fas na dirección correcta, obtés as vinte mil derivadas polo custo aproximado de calcular a perda unha soa vez.
Ese mecanismo é a diferenciación automática en modo inverso. Aplicado a unha rede neuronal chámase backpropagation, e ao final deste capítulo terás escrito unha en arredor de 120 liñas de Python sen bibliotecas, comprobaraa contra PyTorch e usaraa para resolver o problema XOR que matou o perceptrón no Capítulo 1.
Primeiro: por que ten que haber unha non linearidade
Ligazón á sección: Primeiro: por que ten que haber unha non linearidadeAntes de construír a máquina, hai que pechar unha pregunta, porque se a resposta fose a contraria non habería nada que construír.
O perceptrón fallou con XOR porque unha liña non pode separar os catro puntos. A corrección obvia é amorear: pasar a entrada por unha capa lineal e despois por outra. Axuda iso?
Non, e a proba son dúas liñas. Unha capa lineal é . Dálle iso a outra, , e substitúe:
A composición é con e . Unha pila de capas lineais é unha única capa lineal. Dez delas, mil delas: segue sendo unha liña, segue sen poder facer XOR.
Paga a pena velo ocorrer en vez de crelo:
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+00Non aproximadamente iguais. Idénticas bit a bit, porque é a mesma aritmética reordenada.
Así que a profundidade por si soa non compra nada. O que compra algo é poñer unha función non lineal entre as capas, e esa é toda a razón de existir das funcións de activación. Non son un adorno biolóxico nin un truco de normalización. Sen unha, a segunda capa é decoración.
A regra da cadea, no papel, cun nodo compartido
Ligazón á sección: A regra da cadea, no papel, cun nodo compartidoAgora as matemáticas, e é unha soa regra que xa coñeces aplicada nun lugar lixeiramente menos familiar.
A regra da cadea dunha soa variable di que se depende de e depende de , entón . As derivadas multiplícanse ao longo dunha cadea.
A parte que importa aquí é o que ocorre cando unha variable alimenta máis dun camiño descendente. Se inflúe en a través de e tamén a través de , as contribucións súmanse:
Multiplica ao longo dun camiño, suma entre camiños. Iso é todo o backpropagation, e cada detalle de implementación no resto deste capítulo — incluído o += no código e a chamada zero_grad() que fai tropezar a todo o mundo cando escribe o seu primeiro bucle de adestramento — é unha consecuencia directa desa segunda palabra.
Colle un circuíto concreto de cinco operacións, con e :
Observa que aparece tres veces: en , en e directamente en . Fai a pasada cara atrás no papel, de dereita a esquerda, comezando por :
A través da suma
Ligazón á sección: A través da suma, así que e o camiño directo contribúe . A suma distribúe o gradiente entrante sen cambios a ambas as entradas.
A través da tanh
Ligazón á sección: A través da tanhcon , así que .
A través da multiplicación
Ligazón á sección: A través da multiplicación, así que e . A multiplicación intercambia: o gradiente de cada entrada escálase polo valor da outra entrada.
Recolle os tres camiños en x
Ligazón á sección: Recolle os tres camiños en xA través de : . A través de : . Directamente: .
Queda con ese número. En poucas páxinas, un programa vaino producir sen que lle contemos nada disto.
Construír o motor
Ligazón á sección: Construír o motorA idea que o fai programable: cada un deses pasos era local. Para empurrar un gradiente a través do nodo de multiplicación, necesitabas o gradiente entrante e os dous valores de entrada gardados; nada sobre o resto do circuíto. Cada operación sabe como diferenciarse a si mesma.
Así que crea un número que lembre que o produciu.
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 = _opCatro campos. data é o valor. grad acumula . _prev é o conxunto de Values a partir dos cales se calculou este: as arestas do grafo. E _backward é un peche que instala cada operación: sabe como empurrar o gradiente deste nodo un paso cara atrás ata as súas entradas.
Cada operador segue a mesma forma: calcula a saída, rexistra 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 outLe os catro corpos de _backward como unha táboa e os patróns de fluxo da derivación en papel están aí mesmo:
| operación | que lle fai ao gradiente |
|---|---|
+ | distribúe — o mesmo gradiente a cada entrada |
* | intercambia — cada entrada escalada polo valor da outra |
relu | encamiña — déixao pasar ou bloquéao por completo |
tanh | atenúa — escala por , que como moito é 1 e normalmente é menor |
Cada unha usa += e nunca =. Esa é a regra de «sumar entre camiños», codificada. Un nodo que alimenta dous consumidores chámase dúas veces, e as dúas contribucións súmanse soas.
Despois vén o controlador, que é a única parte con coñecemento 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 produce unha orde topolóxica do grafo: cada nodo aparece despois de todas as súas entradas. Percorrer esa lista ao revés garante que, cando chamas ao _backward dun nodo, o seu propio gradiente xa está completo: todos os consumidores por debaixo del xa contribuíron. Equivócate na orde e empurras cara atrás un gradiente a medio facer, o que produce unha resposta incorrecta sen ningunha mensaxe de erro.
Coincide co papel?
Ligazón á sección: Coincide co 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, dun programa ao que só se lle contou a regra de +, a regra de *, a regra de tanh, e nada sobre este circuíto.
Dúas comprobacións independentes, porque «coincide co que derivei» é unha proba feble cando a mesma persoa fixo as dúas cousas.
Diferenciación numérica. Move un chisco a entrada e mide. A diferenza centrada estima a derivada sen ningún cálculo diferencial:
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 ten un motor autodiff industrial escrito por xente que se dedica a isto:
torch dL/dx=1.821202805316 ours=1.821202805316 |diff|=2.22e-16
torch dL/dy=0.403269234753 ours=0.403269234753 |diff|=1.11e-16Acordo en , que é o épsilon de máquina para un float de 64 bits: os dous motores están facendo aritmética idéntica. Garda a comprobación numérica no peto: é a ferramenta para depurar a pasada cara atrás dunha capa nova, e é a razón pola que un gradiente incorrecto se pode atopar.
Saturación, medida
Ligazón á sección: Saturación, medidaO mesmo circuíto, entradas distintas. Pon e , o que fai :
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 cruza o nodo caeu por un factor de 9.945. Todo o que hai augas arriba del — nunha rede real, cada capa antes del — recibe practicamente nada. Os dous camiños a través do circuíto calaron; só a conexión directa que salta segue levando sinal.
Ese é o problema do gradiente que desaparece, nun só nodo. Amorea corenta capas de e multiplica corenta factores coma ese, e as primeiras capas deixan de aprender por completo. Tamén é, de paso, un argumento a favor das conexións de salto que podes ver aquí en miniatura: o camiño que evitou a non linearidade é o único que sobreviviu.
Que fai realmente zero_grad, e por que o erro se agocha
Ligazón á sección: Que fai realmente zero_grad, e por que o erro se agochaCada _backward usa +=. Iso é correcto: é como se suman os camiños. Pero ten unha consecuencia que colle a todo o mundo: os gradientes tamén se acumulan entre chamadas a backward(). O motor non ten nin idea de que a túa segunda chamada é un novo paso de adestramento en vez doutro camiño no mesmo grafo.
Así que un bucle de adestramento ten que limpalos:
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() en PyTorch, e o consello habitual é que esquecelo rompe o adestramento. Así que imos borrar esas dúas liñas e ver canto rompe. Mesmas sementes, todo igual, 200 pasos de XOR:
| learning rate | seed | con reinicio | sen reinicio |
|---|---|---|---|
| 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 |
Coas taxas de aprendizaxe pequenas, a versión con erro gaña en todas as filas. Converxe cando a versión correcta queda atascada.
Iso non é casualidade e paga a pena entendelo, porque explica por que este erro é tan difícil de detectar. Se nunca limpas o gradiente, entón no paso o parámetro actualízase coa suma de todos os gradientes calculados ata ese momento. Nunha perda que segue apuntando máis ou menos na mesma dirección, esa suma medra de forma constante, e o efecto é unha taxa de aprendizaxe que aumenta soa. En , onde o algoritmo correcto vai arrastrándose, ese tamaño de paso desbocado parece exactamente unha corrección.
Despois mira as tres filas inferiores. En o mesmo mecanismo rebenta o modelo: perda 8.0 é o que puntúa un modelo colapsado a unha constante — a metade dos 16 que custarían catro respostas maximamente erradas — — mentres a versión correcta agora converxe con limpeza.
Así que a afirmación honesta non é «chama sempre a zero_grad ou o teu modelo non se adestrará». É: sen iso xa non estás executando gradient descent. Estás executando algo cuxo tamaño de paso deriva cara arriba a un ritmo que ninguén escolleu, e parecerá funcionar, ás veces mellor que o real, xusto ata que deixe de facelo; nese momento culparás a taxa de aprendizaxe, a inicialización ou os datos. Esta é a forma dos peores erros en machine learning: non fallan, transforman o algoritmo noutro algoritmo distinto que ás veces puntúa mellor.
A rede, e XOR por fin
Ligazón á sección: A rede, e XOR por finCo motor rematado, unha rede neuronal é moi pouco código. Unha neurona é un produto escalar, un nesgo e unha activación; unha capa é unha lista de neuronas; unha rede é unha lista de capas.
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()]Non hai ningunha pasada cara atrás en nada diso. Nin unha liña. A clase Value xa sabe como diferenciar o que sexa que estas clases constrúan, que é a razón de tela escrito primeiro: un motor autodiff non sabe que se está usando para unha rede neuronal.
Agora o problema do Capítulo 1. Dúas entradas, dúas unidades ocultas, unha 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) okCatro de catro. A función que ningún perceptrón pode calcular — demostrado no Capítulo 1 con catro desigualdades que esixían que fose á vez positivo e negativo — calcúlana nove números atopados automaticamente.
Que fixo a capa oculta
Ligazón á sección: Que fixo a capa ocultaO satisfactorio non é que funcione. É poder ver como, porque con dúas unidades ocultas a representación intermedia é un punto nun plano e podes imprimila.
Adestrada ata unha perda de 0.001241, aquí é onde aterra cada entrada despois da capa oculta, e que fai con ela a neurona de saída:
| entrada | saída da capa oculta | puntuación de saída | etiqueta |
|---|---|---|---|
Mira a primeira e a cuarta filas. As entradas e son esquinas diagonalmente opostas do cadrado — tan afastadas como poden estar dous puntos neste problema — e a capa oculta asígnaas a e . Case o mesmo punto. A capa dobrou o plano para que as dúas esquinas rexeitadas caian unha enriba da outra, e cando están no mesmo sitio, unha liña sepáraas das outras dúas.
E a neurona de saída é exactamente esa liña. Os seus parámetros aprendidos son , , así que a súa fronteira de decisión é
que é unha recta: un perceptrón, o mesmo obxecto do Capítulo 1, sen cambios. Daquela non podía resolver XOR e agora tampouco pode. O que cambiou é que xa non está mirando a entrada; está mirando un espazo que a primeira capa construíu para el, no que o problema é linealmente separable.
Iso é unha representación aprendida, e paga a pena ser preciso porque a frase úsase con laxitude durante o resto deste curso e no resto do campo. Non é unha compresión, un resumo nin un embedding en ningún sentido místico. É un cambio de coordenadas, aprendido en vez de deseñado, cuxo único traballo é facer fácil o traballo da seguinte capa.
O teorema de aproximación universal, e o que non di
Ligazón á sección: O teorema de aproximación universal, e o que non diAquí hai un teorema, e adoita citarse mal.
Cybenko en 1989 e Hornik en 1991 demostraron que unha rede feedforward cunha soa capa oculta e unha función de activación axeitada pode aproximar calquera función continua nun conxunto compacto, coa precisión que queiras, se se lle dan unidades ocultas abondas.34 É un resultado real e importante: di que a arquitectura non é a limitación.
Agora le o que omite. Non di cantas unidades: a cota pode ser astronomicamente grande. Non di que os pesos poidan atoparse; afirma existencia, e gradient descent desde un inicio aleatorio non é un oráculo. E non di nada sobre o comportamento en datos que non viches, que é a segunda metade do Capítulo 6.
A fenda entre «existe» e «atopable» non é académica. Aquí está o mesmo problema XOR, 50 inicializacións aleatorias cada unha, 1000 pasos, cambiando só o tamaño da capa oculta:
| unidades ocultas | inicializacións que chegan a 4/4 |
|---|---|
| 2 | 38 / 50 (76 %) |
| 3 | 49 / 50 (98 %) |
| 4 | 50 / 50 (100 %) |
| 8 | 47 / 50 (94 %) |
Coa arquitectura mínima viable, unha execución de cada catro nunca chega: aséntase nunha configuración da que non pode descender, exactamente o mínimo local que o Capítulo 3 mostrou nunha superficie unidimensional. Engade unha unidade e os fallos case desaparecen, non porque a rede se volvese máis expresiva (dúas unidades xa abondan; 38 execucións demóstrano), senón porque as dimensións extra danlle ao descenso máis direccións polas que escapar.
E despois oito unidades funcionan lixeiramente peor ca catro. Cunha taxa de aprendizaxe e un orzamento de pasos fixos, máis capacidade non é monotonicamente mellor. Calquera que che diga que a solución para unha rede atascada sempre é unha rede máis grande está extrapolando desde o medio desa táboa.
Esta é a mesma lección ca o teorema de converxencia do Capítulo 1, e será a mesma lección no Capítulo 10 sobre leis de escalado, na forma que lle dá ese capítulo: unha predición da perda non é unha predición da capacidade pola que estás pagando, e a distancia entre as dúas é onde vive a enxeñaría.
Mostrar detalles
Opcional: a forma matricial, e por que o código anterior non a usa.
Todo aquí foi escrito un escalar cada vez, que é a forma máis clara de ver o mecanismo e a máis lenta de executalo. Na práctica, unha capa é unha multiplicación de matrices, e a pasada cara atrás de é
As transpostas non son un truco para lembrar; son o aspecto que ten a regra de sumar entre camiños cando os camiños están indexados polas entradas da matriz. O obxecto xeral é o xacobiano, a matriz de todas as derivadas parciais de todas as saídas con respecto a todas as entradas, e o modo inverso é precisamente o cálculo dun produto vector-xacobiano sen formar nunca o xacobiano, cousa que importa, porque para unha capa con 4096 entradas e 4096 saídas esa matriz ten dezaseis millóns de entradas e nunca paga a pena construíla.
Non necesitas nada disto para seguir os próximos capítulos; a versión escalar fai todo o que fai a versión matricial, máis amodo. Vólvese necesaria no Capítulo 9, onde as formas deixan de ser obvias.
Cara a onde imos agora
Ligazón á sección: Cara a onde imos agoraAgora tes unha rede que se adestra. É un logro máis pequeno do que parece, porque a rede que tes adéstrase con catro exemplos e mídese cos mesmos catro.
Executa o mesmo código nun conxunto de datos real e aparece unha nova serie de problemas, ningún dos cales vai de gradientes. A perda baixa durante un tempo e despois para. Ou baixa nos datos de adestramento e sobe en todo o demais. Ou non se move en absoluto desde o primeiro paso, e a causa resulta ser o rango dos pesos aleatorios iniciais. Ou a entrada dunha unidade derivou a negativa en todos os exemplos na terceira época e leva morta desde entón, en silencio, levando con ela un anaco da capacidade do modelo.
Estes non son fallos exóticos; son a condición normal dunha rede que acaba de escribirse, e ningún se anuncia a si mesmo. O gradiente é correcto — comprobáchelo contra PyTorch ata dezaseis cifras decimais — e o modelo segue sen aprender.
O Capítulo 6 vai diso: inicialización, normalización, overfitting e regularización, e o hábito diagnóstico de preguntar cal desas cousas está a pasar antes de cambiar nada. É a diferenza entre unha rede que se executa e unha rede que funciona.
Fontes e método
Ligazón á sección: Fontes e métodoA clase Value deste capítulo descende directamente de micrograd, de Andrej Karpathy, e o seu vídeo The spelled-out intro to neural networks and backpropagation: building micrograd é as mellores tres horas que podes investir neste material se queres que outra persoa cho explique dunha segunda maneira. A súa publicación de 2016 Yes you should understand backprop defende a idea de escribir un ti mesmo e é lectura asignada no CS224n de Stanford. As notas de CS231n sobre backpropagation (cs231n.github.io/optimization-2) son o tratamento canónico dos patróns de fluxo tabulados arriba. Para as matemáticas como cálculo nun grafo, e non como folclore de redes neuronais, o capítulo 5.6 de Mathematics for Machine Learning de Deisenroth, Faisal e Ong é inusualmente claro; e a revisión de Baydin, Pearlmutter, Radul e Siskind Automatic Differentiation in Machine Learning: a Survey (arXiv:1502.05767) é a referencia para o campo no seu conxunto, incluído o compromiso entre modo directo e inverso discutido arriba.
Referencias
Ligazón á sección: Referencias-
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 Helsinqui (1970). Acumulación en modo inverso, dezaseis anos antes de chegar a este campo e cunha motivación completamente distinta. ↩
-
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 deu a coñecer o método, e a fonte da lectura das unidades ocultas como representacións aprendidas á que a sección Que fixo a capa oculta deste capítulo dedica as súas medicións. ↩
-
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). Xeneraliza Cybenko: o resultado depende de que a activación sexa non polinómica, non de que sexa sigmoidal. ↩