Backpropagation desde cero: primero el motor, luego la red
Escribe un motor autodiff de 120 líneas en Python puro, compáralo con PyTorch hasta 16 decimales y entiende zero_grad borrándolo.
En esta página
Cuatro capítulos después, hay un agujero en medio del curso.
El Capítulo 3 nos dio gradient descent: para mejorar un parámetro, encuentra la pendiente de la pérdida con respecto a él y baja cuesta abajo. El Capítulo 4 nos dio una pérdida que merecía la pena descender. Pero en ambos casos, la derivada se calculaba a mano: un modelo, un parámetro, una línea de cálculo diferencial, y todo cabía en una página.
Ahora apila dos capas. La salida de la primera alimenta la segunda, así que cada peso de la primera afecta a la pérdida a través de cada neurona de la segunda. Una red con dos capas ocultas de cien unidades cada una tiene unos veinte mil parámetros, y cada uno necesita su propia derivada parcial de la misma pérdida. Hacer eso a mano no es tedioso; es imposible, y seguirá siéndolo para cada arquitectura del resto de este curso.
La salida no es una notación mejor. Es darse cuenta de que la derivada de una composición puede calcularse mecánicamente, mediante un programa, a partir de la propia estructura del cálculo; y de que, si lo haces en la dirección correcta, obtienes las veinte mil derivadas por aproximadamente el coste de calcular la pérdida una vez.
Ese mecanismo es la diferenciación automática en modo inverso. Aplicada a una red neuronal se llama backpropagation, y al final de este capítulo habrás escrito una en unas 120 líneas de Python sin librerías, la habrás comprobado contra PyTorch y la habrás usado para resolver el problema XOR que mató al perceptrón en el Capítulo 1.
Primero: por qué tiene que haber una no linealidad
Enlace a la sección: Primero: por qué tiene que haber una no linealidadAntes de construir la máquina, hay que resolver una pregunta, porque si la respuesta fuera la contraria no habría nada que construir.
El perceptrón falló con XOR porque una recta no puede separar los cuatro puntos. La solución obvia es apilar: pasa la entrada por una capa lineal y luego por otra. ¿Ayuda eso?
No, y la prueba son dos líneas. Una capa lineal es . Aliméntala a otra, , y sustituye:
La composición es con y . Una pila de capas lineales es una sola capa lineal. Diez de ellas, mil de ellas: sigue siendo una recta, sigue sin poder hacer XOR.
Merece la pena verlo ocurrir en vez de creerlo:
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+00No son aproximadamente iguales. Son idénticas bit a bit, porque es la misma aritmética reorganizada.
Así que la profundidad no compra nada por sí sola. Lo que sí compra algo es poner una función no lineal entre las capas, y esa es toda la razón por la que existen las funciones de activación. No son un adorno biológico ni un truco de normalización. Sin una, la segunda capa es decoración.
La regla de la cadena, en papel, con un nodo compartido
Enlace a la sección: La regla de la cadena, en papel, con un nodo compartidoAhora las matemáticas, y es una regla que ya conoces aplicada en un lugar un poco menos familiar.
La regla de la cadena de una variable dice que si depende de y depende de , entonces . Las derivadas se multiplican a lo largo de una cadena.
La parte que importa aquí es qué ocurre cuando una variable alimenta más de una ruta descendente. Si influye en a través de y también a través de , las contribuciones se suman:
Multiplica a lo largo de una ruta, suma entre rutas. Eso es todo backpropagation, y cada detalle de implementación del resto de este capítulo —incluido el += en el código y la llamada zero_grad() que hace tropezar a todo el mundo cuando escribe su primer bucle de entrenamiento— es consecuencia directa de esa segunda palabra.
Toma un circuito concreto de cinco operaciones, con y :
Observa que aparece tres veces: en , en y directamente en . Haz la pasada hacia atrás en papel, de derecha a izquierda, empezando por :
A través de la suma
Enlace a la sección: A través de la suma, así que y la ruta directa contribuye . La suma distribuye el gradiente entrante sin cambios a ambas entradas.
A través de tanh
Enlace a la sección: A través de tanhcon , así que .
A través de la multiplicación
Enlace a la sección: A través de la multiplicación, así que y . La multiplicación intercambia: el gradiente de cada entrada se escala por la otra entrada.
Reúne las tres rutas hacia x
Enlace a la sección: Reúne las tres rutas hacia xA través de : . A través de : . Directamente: .
Quédate con ese número. En unas páginas un programa va a producirlo sin que nadie le haya contado nada de esto.
Construir el motor
Enlace a la sección: Construir el motorLa idea que lo vuelve programable: cada uno de esos pasos era local. Para empujar un gradiente a través del nodo de multiplicación, necesitabas el gradiente entrante y los dos valores de entrada almacenados; nada sobre el resto del circuito. Cada operación sabe cómo diferenciarse a sí misma.
Así que crea un número que recuerde qué lo produjo.
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 = _opCuatro campos. data es el valor. grad acumula . _prev es el conjunto de Values de los que se calculó este: las aristas del grafo. Y _backward es un closure que instala cada operación: sabe cómo empujar el gradiente de este nodo un paso hacia atrás, a sus entradas.
Cada operador sigue la misma forma: calcula la salida, registra los padres, instala la regla 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 outLee los cuatro cuerpos de _backward como una tabla y los patrones de flujo de la derivación en papel están ahí mismo:
| operación | qué le hace al gradiente |
|---|---|
+ | distribuye — el mismo gradiente a cada entrada |
* | intercambia — cada entrada escalada por el valor de la otra |
relu | enruta — lo deja pasar o lo bloquea por completo |
tanh | atenúa — escala por , que como mucho es 1 y normalmente es menor |
Todos y cada uno usan += y nunca =. Esa es la regla de «sumar entre rutas», codificada. Un nodo que alimenta a dos consumidores se llama dos veces, y las dos contribuciones se suman solas.
Luego el controlador, que es la única parte con conocimiento 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 un ordenamiento topológico del grafo: cada nodo aparece después de todas sus entradas. Recorrer esa lista en sentido inverso garantiza que, cuando llamas al _backward de un nodo, su propio gradiente ya está completo: cada consumidor aguas abajo ya ha contribuido. Equivócate de orden y empujas hacia atrás un gradiente a medio hacer, lo que produce una respuesta incorrecta sin ningún mensaje de error.
¿Coincide con el papel?
Enlace a la sección: ¿Coincide con el 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. El mismo número, desde un programa al que se le contó la regla de +, la regla de *, la regla de tanh, y nada sobre este circuito.
Dos comprobaciones independientes, porque «coincide con lo que he derivado» es una prueba débil cuando la misma persona hizo ambas cosas.
Diferenciación numérica. Mueve ligeramente la entrada y mide. La diferencia centrada estima la derivada sin nada de 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 tiene un motor autodiff industrial escrito por gente que se dedica a esto:
torch dL/dx=1.821202805316 ours=1.821202805316 |diff|=2.22e-16
torch dL/dy=0.403269234753 ours=0.403269234753 |diff|=1.11e-16Coincidencia en , que es el épsilon de máquina para un float de 64 bits: los dos motores están haciendo aritmética idéntica. Guarda la comprobación numérica en el bolsillo: es la herramienta para depurar la pasada hacia atrás de una nueva capa, y es la razón por la que un gradiente incorrecto se puede encontrar.
Saturación, medida
Enlace a la sección: Saturación, medidaEl mismo circuito, entradas distintas. Pon y , lo que hace :
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.9999El gradiente que cruza el nodo cayó por un factor de 9.945. Todo lo que está aguas arriba de él —en una red real, cada capa anterior— recibe prácticamente nada. Las dos rutas a través del circuito se han quedado en silencio; solo la conexión directa que se salta sigue transportando señal.
Ese es el problema del gradiente que se desvanece, en un solo nodo. Apila cuarenta capas de y multiplica cuarenta factores así, y las capas tempranas dejan de aprender por completo. También es, de paso, un argumento a favor de las conexiones de salto que puedes ver aquí en miniatura: la ruta que evitó la no linealidad es la única que sobrevivió.
Qué hace realmente zero_grad, y por qué el bug se esconde
Enlace a la sección: Qué hace realmente zero_grad, y por qué el bug se escondeCada _backward usa +=. Eso es correcto: así es como se suman las rutas. Pero tiene una consecuencia que atrapa a todo el mundo: los gradientes también se acumulan entre llamadas a backward(). El motor no tiene ni idea de que tu segunda llamada es un nuevo paso de entrenamiento y no otra ruta del mismo grafo.
Así que un bucle de entrenamiento tiene que limpiarlos:
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.gradEsto es optimizer.zero_grad() en PyTorch, y el consejo habitual es que olvidarlo rompe el entrenamiento. Así que borremos esas dos líneas y veamos lo roto que queda. Mismas semillas, todo igual, 200 pasos de XOR:
| tasa de aprendizaje | seed | con reinicio | sin reinicio |
|---|---|---|---|
| 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 |
Con tasas de aprendizaje pequeñas, la versión con bug gana en todas las filas. Converge cuando la versión correcta se atasca.
No es casualidad y merece la pena entenderlo, porque explica por qué este bug es tan difícil de detectar. Si nunca limpias el gradiente, entonces en el paso el parámetro se actualiza con la suma de todos los gradientes calculados hasta ese momento. En una pérdida que sigue apuntando más o menos en la misma dirección, esa suma crece de forma constante, y el efecto es una tasa de aprendizaje que aumenta sola. En , donde el algoritmo correcto avanza a rastras, el tamaño de paso desbocado parece exactamente una solución.
Ahora mira las tres filas inferiores. En el mismo mecanismo hace saltar el modelo por los aires: loss 8.0 es lo que obtiene un modelo colapsado a una constante —la mitad de los 16 que costarían cuatro respuestas máximamente equivocadas— — mientras que la versión correcta ahora converge limpiamente.
Así que la afirmación honesta no es «llama siempre a zero_grad o tu modelo no entrenará». Es: sin ello ya no estás ejecutando gradient descent. Estás ejecutando algo cuyo tamaño de paso deriva hacia arriba a una velocidad que nadie eligió, y parecerá funcionar, a veces mejor que lo real, justo hasta que deje de hacerlo; momento en el que culparás a la tasa de aprendizaje, a la inicialización o a los datos. Esta es la forma de los peores bugs en machine learning: no se bloquean, convierten el algoritmo en otro algoritmo distinto que de vez en cuando puntúa mejor.
La red, y por fin XOR
Enlace a la sección: La red, y por fin XORCon el motor terminado, una red neuronal apenas requiere código. Una neurona es un producto escalar, un sesgo y una activación; una capa es una lista de neuronas; una red es una 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()]No hay pasada hacia atrás en nada de eso. Ni una línea. La clase Value ya sabe cómo diferenciar cualquier cosa que estas clases construyan, que es el objetivo de haberla escrito primero: un motor autodiff no sabe que se está usando para una red neuronal.
Ahora el problema del Capítulo 1. Dos entradas, dos unidades ocultas, una salida, nueve 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) okCuatro de cuatro. La función que ningún perceptrón puede calcular —demostrado en el Capítulo 1 mediante cuatro desigualdades que exigían que fuera a la vez positivo y negativo— la calculan nueve números encontrados automáticamente.
Qué hizo la capa oculta
Enlace a la sección: Qué hizo la capa ocultaLo satisfactorio no es que funcione. Es poder ver cómo, porque con dos unidades ocultas la representación intermedia es un punto en un plano y puedes imprimirla sin más.
Entrenada hasta una pérdida de 0.001241, aquí está dónde cae cada entrada después de la capa oculta y qué hace la neurona de salida con ella:
| entrada | salida de la capa oculta | puntuación de salida | etiqueta |
|---|---|---|---|
Mira la primera y la cuarta fila. Las entradas y son esquinas diagonalmente opuestas del cuadrado —tan alejadas como pueden estar dos puntos en este problema— y la capa oculta las mapea a y . Casi el mismo punto. La capa ha plegado el plano para que las dos esquinas rechazadas caigan una encima de la otra, y una vez que están en el mismo sitio, una recta las separa de las otras dos.
Y la neurona de salida es exactamente esa recta. Sus parámetros aprendidos son , , así que su frontera de decisión es
que es una línea recta: un perceptrón, el mismo objeto del Capítulo 1, sin cambios. No podía resolver XOR entonces y no puede ahora. Lo que ha cambiado es que ya no mira la entrada; mira un espacio que la primera capa construyó para él, en el que el problema es linealmente separable.
Eso es una representación aprendida, y conviene ser preciso porque la expresión se usa de forma imprecisa durante el resto de este curso, y en el resto del campo. No es una compresión, un resumen ni un embedding en ningún sentido místico. Es un cambio de coordenadas, aprendido en lugar de diseñado, cuya única tarea es facilitarle el trabajo a la siguiente capa.
El teorema de aproximación universal, y lo que no dice
Enlace a la sección: El teorema de aproximación universal, y lo que no diceAquí hay un teorema, y normalmente se cita mal.
Cybenko en 1989 y Hornik en 1991 demostraron que una red feedforward con una sola capa oculta y una función de activación adecuada puede aproximar cualquier función continua en un conjunto compacto, con la precisión que quieras, si dispone de suficientes unidades ocultas.34 Es un resultado real e importante: dice que la arquitectura no es la limitación.
Ahora lee lo que omite. No dice cuántas unidades: la cota puede ser astronómicamente grande. No dice que los pesos se puedan encontrar; afirma existencia, y gradient descent desde un inicio aleatorio no es un oráculo. Y no dice nada sobre el comportamiento en datos que no has visto, que es la segunda mitad del Capítulo 6.
La distancia entre «existe» y «se puede encontrar» no es académica. Aquí está el mismo problema XOR, 50 inicializaciones aleatorias cada una, 1000 pasos, cambiando solo el tamaño de la capa oculta:
| unidades ocultas | inicializaciones que alcanzan 4/4 |
|---|---|
| 2 | 38 / 50 (76 %) |
| 3 | 49 / 50 (98 %) |
| 4 | 50 / 50 (100 %) |
| 8 | 47 / 50 (94 %) |
Con la arquitectura mínima viable, una de cada cuatro ejecuciones nunca llega: se asienta en una configuración de la que no puede descender, exactamente el mínimo local que el Capítulo 3 mostró en una superficie unidimensional. Añade una unidad y los fallos casi desaparecen, no porque la red se haya vuelto más expresiva (dos unidades ya bastan: 38 ejecuciones lo demuestran), sino porque las dimensiones extra dan al descenso más direcciones por las que escapar.
Y luego ocho unidades funciona ligeramente peor que cuatro. Con una tasa de aprendizaje y un presupuesto de pasos fijos, más capacidad no es monótonamente mejor. Cualquiera que te diga que la solución para una red atascada siempre es una red más grande está extrapolando desde el centro de esa tabla.
Esta es la misma lección que el teorema de convergencia del Capítulo 1, y será la misma lección en el Capítulo 10 sobre leyes de escalado, en la forma que le da ese capítulo: una predicción de la pérdida no es una predicción de la capacidad por la que estás pagando, y la distancia entre ambas es donde vive la ingeniería.
Mostrar detalles
Opcional: la forma matricial, y por qué el código anterior no la usa.
Todo aquí se ha escrito un escalar cada vez, que es la forma más clara de ver el mecanismo y la más lenta de ejecutarlo. En la práctica, una capa es una multiplicación de matrices, y la pasada hacia atrás de es
Las transpuestas no son un truco para memorizar; son el aspecto que tiene la regla de sumar sobre rutas cuando las rutas están indexadas por entradas de matrices. El objeto general es el jacobiano, la matriz de todas las derivadas parciales de todas las salidas con respecto a todas las entradas, y el modo reverse es precisamente el cálculo de un producto vector-jacobiano sin formar nunca el jacobiano, lo cual importa, porque para una capa con 4096 entradas y 4096 salidas esa matriz tiene dieciséis millones de entradas y nunca merece la pena construirla.
No necesitas nada de esto para seguir los próximos capítulos; la versión escalar hace todo lo que hace la versión matricial, más despacio. Se vuelve necesaria en el Capítulo 9, donde las formas dejan de ser obvias.
Adónde vamos ahora
Enlace a la sección: Adónde vamos ahoraAhora tienes una red que entrena. Es un logro menor de lo que parece, porque la red que tienes entrena con cuatro ejemplos y se mide sobre los mismos cuatro.
Ejecuta el mismo código con un conjunto de datos real y aparece una nueva serie de problemas, ninguno de los cuales va de gradientes. La pérdida baja durante un tiempo y luego se detiene. O baja en los datos de entrenamiento y sube en todo lo demás. O no se mueve en absoluto desde el primer paso, y la causa resulta ser el rango de los pesos aleatorios iniciales. O la entrada de una unidad derivó a negativo en cada ejemplo de la época tres y lleva muerta desde entonces, en silencio, llevándose consigo un trozo de la capacidad del modelo.
No son fallos exóticos; son la condición normal de una red que acaba de escribirse, y ninguno se anuncia. El gradiente es correcto —lo has comprobado contra PyTorch hasta dieciséis decimales— y aun así el modelo no aprende.
El Capítulo 6 trata de eso: inicialización, normalización, overfitting y regularización, y el hábito diagnóstico de preguntar cuál de esas cosas está ocurriendo antes de cambiar nada. Es la diferencia entre una red que se ejecuta y una red que funciona.
Fuentes y método
Enlace a la sección: Fuentes y métodoLa clase Value de este capítulo desciende directamente de micrograd de Andrej Karpathy, y su vídeo The spelled-out intro to neural networks and backpropagation: building micrograd es las mejores tres horas que puedes dedicar a este material si quieres que otra persona te lo explique de otra forma. Su post de 2016 Yes you should understand backprop argumenta por qué deberías escribir uno tú mismo y es lectura asignada en CS224n de Stanford. Las notas de CS231n sobre backpropagation (cs231n.github.io/optimization-2) son el tratamiento canónico de los patrones de flujo tabulados arriba. Para las matemáticas como cálculo diferencial sobre un grafo y no como folclore de redes neuronales, el capítulo 5.6 de Mathematics for Machine Learning de Deisenroth, Faisal y Ong es inusualmente claro; y el estudio de Baydin, Pearlmutter, Radul y Siskind Automatic Differentiation in Machine Learning: a Survey (arXiv:1502.05767) es la referencia del campo en su conjunto, incluida la compensación forward/reverse comentada arriba.
Referencias
Enlace a la sección: Referencias-
Linnainmaa, S. The representation of the cumulative rounding error of an algorithm as a Taylor expansion of the local rounding errors. Tesis de máster, Universidad de Helsinki (1970). Acumulación en modo reverse, dieciséis años antes de llegar a este campo y con una motivación completamente distinta. ↩
-
Rumelhart, D. E., Hinton, G. E. and Williams, R. J. Learning representations by back-propagating errors. Nature 323, pp. 533–536 (1986). El artículo que dio a conocer el método, y la fuente de la lectura de las unidades ocultas como representaciones aprendidas a la que la sección Qué hizo la capa oculta de este capítulo dedica sus mediciones. ↩
-
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 a Cybenko: el resultado depende de que la activación sea no polinómica, no de que sea sigmoidal. ↩