Lección 5 de 10 · Aprender · 15 min
Backpropagation a mano
La regla de la cadena en una red de dos capas
Notebook del episodio
El código de esta lección, para correrlo y modificarlo en Google Colab, gratis y con tu cuenta. Para guardar tus cambios: Archivo → Guardar una copia en Drive.
Deep learning a secas. Episodio cinco: backpropagation a mano. Ya tenemos una red y una pérdida que mide cuánto se equivoca. Falta lo central: saber, para cada peso, hacia dónde moverlo para que la pérdida baje. Hoy lo calculamos a mano, sin PyTorch, en una red de dos capas, y al final la red aprende XOR sola.
El plan
- Gradiente: cuánto cambia la pérdida si movés un peso un poquito
- Regla de la cadena: multiplicar pendientes en fila
- Backward: recorrer la red al revés, reusando el forward
- Descenso por gradiente: mover cada peso en contra de su gradiente
Cuatro ideas. El gradiente de un peso es la pendiente de la pérdida respecto de ese peso: cuánto sube o baja la pérdida si lo movés un poquito. La regla de la cadena dice que, si una cuenta pasa por varios pasos, la pendiente total es el producto de las pendientes de cada paso. Backpropagation es aplicar esa regla de la salida hacia la entrada, usando los valores que ya calculó el forward. Y con los gradientes, entrenar es mover cada peso un poquito en contra de su gradiente. Eso se llama descenso por gradiente.
Parte 1 · Una neurona, a mano
Arrancamos con la red más chica posible: una neurona sin activación y una pérdida cuadrática.
Derivada, parcial y cadena
la pendiente: mover x un poquito y medir
el forward, con números
regla de la cadena: pendiente por pendiente
Tres herramientas. La derivada es la pendiente, como vimos en el episodio tres: mover x un poquito para cada lado y dividir la diferencia por la distancia. En el dibujo, mirá cómo la pendiente cambia de signo cuando el punto cruza el cero. Una neurona con una sola entrada: x dos, peso tres, sesgo uno. z da siete. La respuesta correcta es cinco, así que la pérdida, al cuadrado, da cuatro. Ahora, ¿cuánto cambia L si muevo w? La d curva indica derivada parcial: mover solo w, con todo lo demás quieto. L depende de w a través de z, así que multiplicamos dos pendientes. La de L respecto de z es dos por z menos y: cuatro. En el dibujo, la tangente de la parábola en dos. La de z respecto de w es x: dos. Producto: ocho.
Backward de una neurona, contra PyTorch
El forward, guardando cada valor intermedio. El backward arranca por el final: la pendiente de L respecto de z. De ahí, la de w: multiplicada por x. La del sesgo: por uno. Y la de la entrada: multiplicada por w. Y verificamos con PyTorch: le pedimos que guarde gradientes y llamamos a backward. Cómo lo hace por dentro es el episodio seis. z siete, L cuatro. Pendiente de L respecto de z, cuatro. De w, ocho. Del sesgo, cuatro. De x, doce. PyTorch da ocho y cuatro: lo mismo.
x, w, b, y = 2.0, 3.0, 1.0, 5.0
# forward: guardamos cada valor intermedio
z = w * x + b # 7
L = (z - y) ** 2 # 4
# backward a mano: regla de la cadena, de atrás hacia adelante
dL_dz = 2 * (z - y) # dL/dz
dL_dw = dL_dz * x # dz/dw = x
dL_db = dL_dz * 1 # dz/db = 1
dL_dx = dL_dz * w # dz/dx = w
print(f"z = {z} L = {L}")
print(f"dL/dz = {dL_dz} dL/dw = {dL_dw}")
print(f"dL/db = {dL_db} dL/dx = {dL_dx}")
# lo mismo con autograd de PyTorch (lo abrimos en el ep. 6)
wt = torch.tensor(w, requires_grad=True); bt = torch.tensor(b, requires_grad=True)
((wt * x + bt - y) ** 2).backward()
print("PyTorch: dL/dw =", wt.grad.item(), " dL/db =", bt.grad.item())
z = 7.0 L = 4.0
dL/dz = 4.0 dL/dw = 8.0
dL/db = 4.0 dL/dx = 12.0
PyTorch: dL/dw = 8.0 dL/db = 4.0
El grafo de cómputo
La misma cuenta como grafo: cada círculo es una operación y cada caja un valor. De izquierda a derecha, x por w, más b, menos y, al cuadrado. El forward llena los valores, arriba de cada nodo: seis, siete, dos, cuatro. Y el backward, de derecha a izquierda, llena los gradientes, abajo. La pérdida respecto de sí misma: uno. El cuadrado multiplica por dos veces dos: cuatro. La resta y la suma dejan pasar el cuatro sin cambio; a y le llega menos cuatro. La multiplicación cruza los valores: a w le llega cuatro por x, ocho; a x, cuatro por w, doce. Son los números del snippet.
Cuatro reglas locales del backward
- Suma: copia el gradiente a cada entrada
- Multiplicación: a cada entrada, el gradiente por la otra
- Cuadrado: multiplica por 2 veces el valor
- ReLU: deja pasar si estaba prendida, corta si no
Mirando el grafo aparece algo muy útil: cada operación solo necesita saber su propia regla, sin mirar el resto de la red. Una suma reparte el gradiente que le llega, igual, a cada una de sus entradas. Por eso al sesgo le llegó el mismo cuatro. Una multiplicación le pasa a cada entrada el gradiente por la otra entrada: a w le tocó cuatro por x, y a x, cuatro por w. El cuadrado multiplica por dos veces el valor que tenía en el forward. Y la ReLU, rectified linear unit, que vamos a ver ahora, deja pasar o corta. Con un puñado de reglas así, uno por operación, se arma cualquier backward. Eso es exactamente lo que vamos a programar en el episodio seis.
Parte 2 · Dos capas, a mano
Ahora una red de verdad: dos entradas, una hidden layer de dos neuronas con ReLU, y una salida.
El forward, con números
x = (1, 2); una fila de W₁ por neurona
la neurona 1 queda apagada
salida lineal; la respuesta correcta es y = 2
El forward primero, porque el backward lo reusa. La entrada es uno, dos. W uno tiene dos filas: la neurona oculta uno da menos uno coma cinco. En el dibujo, la ReLU la lleva a cero. La neurona dos da dos, y la ReLU la deja en dos. Así que h, la salida de la hidden layer, es cero, dos. La salida es lineal: w dos por h. Da uno. La respuesta correcta es dos, así que la pérdida es uno al cuadrado: uno.
El backward, paso por paso
δ: el error que empieza a viajar hacia atrás
capa 2: por h para los pesos, por w₂ para seguir
la ReLU deja pasar (pendiente 1) o corta (0)
cada peso: el error de su neurona por su entrada
Delta es la pendiente de la pérdida respecto de la salida: dos por la diferencia, menos dos. En el dibujo, la tangente en menos uno. La salida es w dos por h, así que, por la cadena, el gradiente de w dos es delta por h: cero y menos cuatro. Y para seguir hacia atrás, el de h es delta por w dos: menos dos y menos uno. La ReLU, celda por celda: el círculo con un punto es multiplicar elemento a elemento. Si la neurona estaba prendida, pendiente uno, el gradiente pasa; si estaba apagada, cero. En el dibujo, mirá el salto de pendiente en el cero. Y cada peso de W uno recibe el error de su neurona por la entrada que lo alimenta: un producto exterior, columna por fila. En el dibujo, menos uno por dos, menos dos.
Las mismas cuentas en NumPy
El forward de las dos capas. El backward arranca con delta. Lo pasamos por w dos hacia la hidden layer. La ReLU multiplica por uno o por cero: z uno mayor que cero da verdadero o falso, que NumPy trata como uno o cero. Y el producto exterior para W uno. Todos los números de la escena anterior: h cero, dos; y sombrero, uno; L, uno. Gradiente de w dos, cero y menos cuatro. De W uno, la primera fila entera en cero: la neurona apagada no aprende nada en este ejemplo. A eso se le llama una neurona muerta, cuando le pasa con todos los ejemplos.
x = np.array([1.0, 2.0]); y = 2.0
W1 = np.array([[0.5, -1.0], [1.0, 0.5]]); b1 = np.zeros(2)
w2 = np.array([1.0, 0.5]); b2 = 0.0
# forward
z1 = W1 @ x + b1; h = np.maximum(0, z1)
y_hat = w2 @ h + b2; L = (y_hat - y) ** 2
print("z1 =", z1, " h =", h)
print("y_hat =", y_hat, " L =", L)
# backward
d = 2 * (y_hat - y) # dL/dy_hat
dw2, db2 = d * h, d # capa 2
dh = d * w2 # vuelve a la hidden layer
dz1 = dh * (z1 > 0) # la ReLU deja pasar o corta
dW1, db1 = np.outer(dz1, x), dz1 # capa 1
print("dw2 =", dw2 + 0.0, " db2 =", db2)
print("dh =", dh, " dz1 =", dz1 + 0.0)
print("dW1 =", (dW1 + 0.0).tolist())
z1 = [-1.5 2. ] h = [0. 2.]
y_hat = 1.0 L = 1.0
dw2 = [ 0. -4.] db2 = -2.0
dh = [-2. -1.] dz1 = [ 0. -1.]
dW1 = [[0.0, 0.0], [-1.0, -2.0]]
Dos formas de verificar
Nunca confíes en un gradiente escrito a mano sin verificarlo. Dos formas. Con PyTorch: los mismos números, marcados para que guarde gradientes. Backward, y leemos punto grad. Y la más básica de todas: la derivada numérica. Movemos un solo peso un poquito hacia abajo y un poquito hacia arriba. Y medimos cuánto cambió la pérdida. Autograd da exactamente nuestras matrices. Y la derivada numérica del peso uno, uno da menos dos, igual que a mano. Se llama gradient check, y es la forma estándar de cazar errores en un backward.
# 1) autograd de PyTorch, con los mismos números
W1 = torch.tensor(W1_np, requires_grad=True)
w2 = torch.tensor(w2_np, requires_grad=True)
x = torch.tensor(x_np)
L = (w2 @ torch.relu(W1 @ x) - y) ** 2
L.backward()
print("dW1:", (W1.grad + 0.0).tolist())
print("dw2:", (w2.grad + 0.0).tolist())
# 2) diferencias finitas: mover un peso un poquito y medir
h = 1e-5
Wm, Wp = W1_np.copy(), W1_np.copy()
Wm[1, 1] -= h; Wp[1, 1] += h
num = (perdida(Wp, 0, w2_np, 0) - perdida(Wm, 0, w2_np, 0)) / (2 * h)
print("dW1[1,1] mano -2.0 numérico", round(num, 4))
dW1: [[0.0, 0.0], [-1.0, -2.0]]
dw2: [0.0, -4.0]
dW1[1,1] mano -2.0 numérico -2.0
Forward y backward
Todo junto, en el dibujo de la red. La entrada. La hidden layer. La salida: eso fue el forward, la señal cruza de izquierda a derecha y guarda cada valor. Y el backward: delta, menos dos, sale de la salida y vuelve capa por capa, multiplicándose en cada paso por la pendiente de lo que atraviesa. Por eso, como vimos en el episodio tres, si esas pendientes son chicas, el error llega a las primeras capas casi en cero. Los colores de las líneas son esquemáticos.
Parte 3 · Bajar la pérdida
Con el gradiente de cada peso, entrenar es dar pasos chiquitos en la dirección contraria.
Un paso de descenso por gradiente
η: learning rate, el tamaño del paso
gradiente negativo → el peso sube
La regla: a cada peso le restamos eta por su gradiente. Eta es el learning rate. El menos es lo importante: si la pendiente es positiva, subir el peso sube la pérdida, así que lo bajamos; y al revés. En el dibujo, mirá cómo, al moverse el punto hacia el mínimo, la pendiente se achica, y con ella el paso. Con nuestros números: el peso uno, uno de W uno vale cero coma cinco y su gradiente es menos dos. Con eta cero coma uno, sube a cero coma siete. En el dibujo, la neurona dos con el peso nuevo: z pasa de dos a dos coma cuatro, h sube, y la salida se acerca a la respuesta correcta.
La red aprende XOR sola
Ahora sí, entrenar. Pesos al azar, con cuatro neuronas ocultas. El forward, para el batch de las cuatro entradas de XOR. El backward es el de antes, pero en forma de matrices, y dividido por N porque la pérdida es un promedio. La ReLU corta igual que antes. Y un paso de descenso para cada peso. Repetimos quinientas veces. La pérdida arranca en cero coma veintitrés, a los cien pasos está en cero coma cero cero uno, y a los quinientos, en cero. Predice cero, uno, uno, cero. Nadie le dijo cómo resolver XOR: encontró los pesos sola.
rng = np.random.default_rng(1)
X = np.array([[0, 0], [0, 1], [1, 0], [1, 1]], dtype=float)
y = np.array([0, 1, 1, 0], dtype=float)
W1, b1 = rng.normal(0, 1, (4, 2)), np.zeros(4) # 4 neuronas ocultas
w2, b2 = rng.normal(0, 1, 4), 0.0
eta, N = 0.1, len(X)
for paso in range(501):
Z1 = X @ W1.T + b1; H = np.maximum(0, Z1) # forward
y_hat = H @ w2 + b2; L = ((y_hat - y) ** 2).mean()
d = 2 * (y_hat - y) / N # backward
dw2, db2 = H.T @ d, d.sum()
dZ1 = np.outer(d, w2) * (Z1 > 0)
dW1, db1 = dZ1.T @ X, dZ1.sum(0)
W1 -= eta * dW1; b1 -= eta * db1 # un paso
w2 -= eta * dw2; b2 -= eta * db2
if paso in (0, 100, 500):
print(f"paso {paso:3}: pérdida {L:.4f}")
print("predice:", y_hat.round(2))
paso 0: pérdida 0.2348
paso 100: pérdida 0.0013
paso 500: pérdida 0.0000
predice: [0. 1. 1. 0.]
La pérdida, paso a paso
La curva de la pérdida en esos quinientos pasos, con los números del snippet. Baja rápido al principio, cuando los gradientes son grandes, y después se aplana: cerca del mínimo la pendiente es chica, y los pasos también. Esta es la curva que vas a mirar en cualquier entrenamiento. En el episodio siete la vamos a mirar también sobre datos que la red no vio.
Rumelhart, Hinton & Williams, 1986 — Learning representations by back-propagating errors · fuente
1986 · «internal ‘hidden’ units which are not part of the input or output come to represent important features of the task domain»
El paper que puso esto en el centro. En mil novecientos ochenta y seis, Rumelhart, Hinton y Williams publicaron en Nature un procedimiento de aprendizaje al que llamaron back-propagation. Lo que destacaron no fue la cuenta, sino el efecto: las unidades ocultas, que no son ni entrada ni salida, terminan representando rasgos importantes de la tarea. Es lo que vimos con XOR: la hidden layer se armó sola una representación donde una recta alcanza.
Lo que hay que llevarse
- Cada gradiente = pendientes multiplicadas en fila
- El backward reusa los valores del forward
- ReLU apagada → gradiente cero: neurona muerta
- Siempre verificá: autograd o diferencias finitas
Cuatro cosas para recordar. Todo gradiente es un producto de pendientes, de la pérdida hacia el peso. El backward necesita los valores del forward: por eso entrenar usa más memoria que solo predecir; hay que guardarlos. Si una ReLU está apagada, por ahí no pasa gradiente. Si le pasa con todos los ejemplos, esa neurona deja de aprender para siempre. Y todo backward escrito a mano se verifica, con autograd o con la derivada numérica.
Resumiendo
- Gradiente: , la pendiente respecto de cada peso
- Cadena:
- Backward: viaja de la salida a la entrada
- ReLU: pasa el gradiente o lo corta
- Descenso: ; XOR aprendido solo
- Próximo: un autograd desde cero, el grafo que arma PyTorch
Papers del episodio
¿Qué tal esta lección?
Sé el primero en calificarla
¿Terminaste la lección?
Márcala para sumar +50 XP y mantener tu racha.
Tutor IA
Contexto: Backpropagation a mano
Conectando con el tutor…