Implementar un MLP desde cero
30 min de lectura
Backpropagation entrega el gradiente de una red entera con una sola pasada hacia atrás, y la lección anterior lo derivó sin saltarse un paso. Lo que produjo fue una lista de números en pantalla. Un gradiente impreso no mueve ningún peso: es una indicación —por dónde se baja, y cuánto le toca a cada parámetro— y alguien tiene que ejecutarla, mil veces seguidas, para que una red pase de contestar cualquier cosa a contestar bien. Ese bucle no está escrito en ninguna celda del curso. Sus cuatro piezas sí, repartidas en cuatro lecciones de este bloque, y juntarlas paga además una deuda abierta desde el principio: los ocho pesos de la neurona de reseñas y los siete que separan las frases de XOR (exclusive or, o exclusivo) los escribí yo, mirando los datos.
El sitio donde comprobarlo está elegido desde entonces. La lección sobre XOR y las capas ocultas demostró que ninguna recta deja está bien y no está mal a un lado y las otras dos frases al otro, y resolvió después las cuatro con una capa oculta cuyos siete números elegí a mano. Si el bucle funciona, la red tiene que dar con unos pesos que acierten las cuatro —no necesariamente los míos— partiendo de números que no significan nada. Y si no funciona se nota enseguida: con cuatro ejemplos y dos coordenadas no hay dónde esconder un fallo.
Las cuatro piezas no están sueltas. Están en un orden, y el orden se cierra sobre sí mismo.
Del ejemplo al batch: la vuelta, apilada por filas
La lección anterior derivó la vuelta para un ejemplo, con vectores columna. La ida ya está escrita para un batch entero desde la lección sobre el forward pass: un ejemplo por fila en , y de ahí capa a capa. Falta el mismo trato para la vuelta, y son dos cambios: uno de forma y otro de tamaño.
El de forma es apilar. Llamemos al error del batch en la capa : su fila es el del ejemplo , tumbado, igual que la fila de es su activación tumbada. Mayúscula para el batch y minúscula para el ejemplo, que es el convenio del bloque. La capa de salida entra igual que antes, ahora con filas a la vez:
Y la recurrencia se transpone entera. Tomemos la de la lección anterior para el ejemplo , , y transpongámosla. Transponer un producto le da la vuelta al orden de sus factores, y el producto de Hadamard opera coordenada a coordenada, de modo que no se entera:
que es la fila de lo que buscamos, escrita con las filas de y de . Apilar las deja
donde lo que hay que mirar es lo que ha desaparecido: la transpuesta. No es un descuido ni una convención distinta. La ida ganó una transpuesta al pasar a filas — se convirtió en — y la vuelta la pierde por el mismo motivo, porque partía de una matriz que ya estaba transpuesta. Las dos veces es el mismo producto leído con los ejemplos en filas.
El cambio de tamaño es el , y viene de la definición de la pérdida. es la media de las de los ejemplos, y derivar una media es promediar las derivadas, así que el gradiente del batch es la media de los gradientes que daría la lección anterior de uno en uno. Esa media es otro producto de matrices:
con el vector de unos de la lección sobre el forward pass. La suma sobre ejemplos que la lección anterior escribía a mano es exactamente el índice que el producto de matrices recorre y descarta: por eso el resultado tiene la forma de y no se acuerda de .
Con eso, la regla del descenso de la lección sobre descenso de gradiente no cambia ni una letra: , aplicada a cada matriz y a cada vector de sesgos por separado. La concesión, dicha en voz alta: cada paso mira entero el conjunto de entrenamiento —los ejemplos con etiqueta de los que dispone la red, aquí las cuatro frases—, igual que hacía aquella lección con las diez reseñas. Partirlo en trozos y dar un paso por trozo hace falta cuando es grande, y éste tiene cuatro filas.
Ya está escrito, entonces, todo lo que necesita la red de las cuatro frases: el perceptrón multicapa (multilayer perceptron, MLP) más pequeño con el que probarlo, con dos entradas, cuatro neuronas ocultas con y una de salida con sigmoide, o sea y . Su ida ocupa una línea por capa,
con y , y su vuelta son las tres fórmulas de arriba con . Doce pesos y cinco sesgos: diecisiete números que hay que poner en algún sitio antes de dar el primer paso.
Cuatro neuronas iguales son una sola neurona
Poner esos diecisiete números es la decisión que ninguna fórmula anterior toma, y la respuesta cómoda —todos a cero— falla. Sigue las cuentas con esa misma red y con los diecisiete nulos. Entonces , y como también ; de ahí , que es para las cuatro frases. Mete eso en los dos gradientes de arriba:
el primero porque es nula y el segundo porque lo es. Con los dos gradientes de la primera capa también se anulan, de modo que el paso deja la red donde estaba y el siguiente encuentra el mismo punto de partida. La capa oculta no se moverá jamás.
Y el cero no es el problema, sino un caso particular de algo más ancho. Toma dos neuronas ocultas y que empiecen con los mismos pesos de entrada, el mismo sesgo y el mismo peso de salida. Lo primero les da la misma preactivación y la misma activación, y ; lo último hace que reciban el mismo error, ; y con el mismo error y la misma activación sus tres gradientes coinciden:
Restar cantidades iguales a cantidades iguales conserva la igualdad, así que las dos neuronas salen del paso tan idénticas como entraron. Y del siguiente. Cuatro copias de una misma neurona calculan lo que calcula una, por muchos pasos que den: la capa oculta tiene la anchura de una neurona por más que diga cuatro. Y una neurona sola no separa estas cuatro frases, que es justo lo que demostró la lección sobre XOR y las capas ocultas.
Nada del algoritmo rompe esa igualdad, porque todo lo que hace es determinista: tiene que venir de fuera, y la fuente más barata de «distintos» es una tirada al azar. Elegir esos diecisiete números de partida es la inicialización, el único punto del entrenamiento donde interviene el azar. Su papel no es el que parece: no aporta ninguna información sobre las frases, sólo separa unas neuronas de otras para que el gradiente pueda empujarlas por caminos distintos. Cuánto valgan también importa —unos pesos diminutos se parecen demasiado al caso de arriba—, y la tercera celda lo mide. Qué escala elegir en general depende de la profundidad de la red, y aparece en el bloque 3, sobre redes recurrentes, cuando el problema pase de ser un inconveniente a ser el tema.
El bucle en NumPy, y la pérdida cayendo
La primera celda es la red con las fórmulas de arriba puestas en NumPy: la ida, la pérdida y la vuelta, sin bucle todavía. Ejecútala y mira la columna de formas y la última línea.
# Las cuatro frases de XOR: x1 vale +1 si aparece «no» y x2 vale +1 si la
# palabra que valora es «bien». La etiqueta vale 1 si la frase habla bien.
X = np.array([[-1., 1.], [-1., -1.], [1., 1.], [1., -1.]])
Y = np.array([[1.], [0.], [0.], [1.]])
B, d0, d1, d2 = 4, 2, 4, 1
rng = np.random.default_rng(0) # semilla fija: verás estos números
W1, b1 = rng.normal(size=(d1, d0)), np.zeros(d1)
W2, b2 = rng.normal(size=(d2, d1)), np.zeros(d2)
def forward(X, W1, b1, W2, b2):
H1 = np.tanh(X @ W1.T + b1) # (B, d1)
return H1, 1.0 / (1.0 + np.exp(-(H1 @ W2.T + b2)))
def perdida(Y_hat, Y):
p = np.clip(Y_hat, 1e-12, 1.0 - 1e-12) # el logaritmo no admite el cero
return -np.mean(Y * np.log(p) + (1.0 - Y) * np.log(1.0 - p))
def backward(X, Y, H1, Y_hat, W2):
D2 = Y_hat - Y # (B, d2): la salida entra sin factor
D1 = (D2 @ W2) * (1.0 - H1 ** 2) # transporta y enmascara: (B, d1)
return (D1.T @ X) / B, D1.sum(0) / B, (D2.T @ H1) / B, D2.sum(0) / B
H1, Y_hat = forward(X, W1, b1, W2, b2)
gW1, gb1, gW2, gb2 = backward(X, Y, H1, Y_hat, W2)
for nombre, A in [("X", X), ("H(1)", H1), ("Ŷ", Y_hat),
("∇W(1)", gW1), ("∇b(1)", gb1), ("∇W(2)", gW2)]:
print("%-6s %s" % (nombre, A.shape))
print("pérdida inicial: %.4f" % perdida(Y_hat, Y))
# El 1/B a la vista: el gradiente del batch es la media de los cuatro de uno en uno.
uno_a_uno = np.zeros((d1, d0))
for i in range(B):
d2_i = Y_hat[i] - Y[i]
d1_i = (W2.T @ d2_i) * (1.0 - H1[i] ** 2) # el mismo δ, en columna
uno_a_uno += np.outer(d1_i, X[i]) / B
print("\n∇W(1) del batch =", np.round(gW1, 4).tolist())
print("diferencia con la media de los cuatro productos exteriores: %.1e"
% np.abs(uno_a_uno - gW1).max())
La primera ejecución descarga el intérprete de Python (~15 MB). Después queda en la caché del navegador.
En la columna de formas está el reparto entero: el de los ejemplos aparece en , en y en , y en ninguno de los tres gradientes, que salen con la forma de sus parámetros. La última línea comprueba el y da cero: los cuatro productos exteriores de la lección anterior, promediados a mano, son casilla por casilla el producto de matrices de arriba.
La segunda celda mete eso en un bucle y lo repite dos mil veces, con . Ejecútala y mira caer la pérdida.
frases = ["está bien", "está mal", "no está bien", "no está mal"]
X = np.array([[-1., 1.], [-1., -1.], [1., 1.], [1., -1.]])
Y = np.array([[1.], [0.], [0.], [1.]])
B, eta, pasos = 4, 0.5, 2000
rng = np.random.default_rng(0) # la misma red de la celda anterior
W1, b1 = rng.normal(size=(4, 2)), np.zeros(4)
W2, b2 = rng.normal(size=(1, 4)), np.zeros(1)
for t in range(pasos + 1):
H1 = np.tanh(X @ W1.T + b1) # ida
Y_hat = 1.0 / (1.0 + np.exp(-(H1 @ W2.T + b2)))
if t in (0, 25, 50, 100, 200, 500, 1000, 2000):
p = np.clip(Y_hat, 1e-12, 1.0 - 1e-12)
print("paso %4d pérdida %.4f aciertos %d de 4"
% (t, -np.mean(Y * np.log(p) + (1.0 - Y) * np.log(1.0 - p)),
int(((Y_hat >= 0.5) == (Y == 1.0)).sum())))
if t == pasos:
break
D2 = Y_hat - Y # vuelta
D1 = (D2 @ W2) * (1.0 - H1 ** 2)
W1 -= eta * (D1.T @ X) / B # y descenso
b1 -= eta * D1.sum(0) / B
W2 -= eta * (D2.T @ H1) / B
b2 -= eta * D2.sum(0) / B
print()
print("neurona w1 w2 sesgo peso de salida")
for j in range(4):
print(" %d %+5.2f %+5.2f %+5.2f %+5.2f"
% (j + 1, W1[j, 0], W1[j, 1], b1[j], W2[0, j]))
print()
aporta = W2[0] * H1 # lo que pone cada neurona en z(2)
for frase, a in zip(frases, aporta):
print("%-13s neuronas 2 y 4: %+5.2f neuronas 1 y 3: %+5.2f"
% (frase, a[1] + a[3], a[0] + a[2]))
La primera ejecución descarga el intérprete de Python (~15 MB). Después queda en la caché del navegador.
La pérdida sale de y llega a , y los cuatro aciertos aparecen antes del paso . La deuda está pagada: nadie le ha dicho a la red que no invierte el signo de lo que sigue, y los pesos que ha encontrado no son los que escribí en la lección sobre XOR y las capas ocultas.
Son mejores que eso: son los mismos, redescubiertos. Mira las neuronas y . Sus pesos apuntan casi en la misma dirección — y , las dos casi —, sus sesgos son opuestos — y — y sus pesos de salida también — y —. Eso es la franja de la lección sobre XOR y las capas ocultas: dos rectas paralelas y una resta entre las dos. La última tabla lo confirma: esa pareja aporta a las dos frases positivas y a las otras dos. Y la pareja y hace lo mismo sobre la otra diagonal con el signo cambiado, restando justo donde la primera se calla.
Falta decir cuándo parar, y es la decisión más floja de las tres. Aquí paro en un número redondo de pasos porque la curva se ha aplanado; podría haber parado cuando la pérdida dejara de bajar una milésima, o cuando la norma del gradiente bajara de un umbral, y las tres son reglas de andar por casa. Los aciertos no sirven para decidirlo: en la fila del paso ya son cuatro y ahí se quedan, mientras la pérdida sigue bajando un factor de trescientos. Y las tres reglas comparten un límite que ninguna puede arreglar, porque las tres miran sólo estas cuatro frases.
La tercera celda vuelve al principio del bucle y cambia una sola cosa: de dónde salen los pesos. Seis escalas con la misma semilla, y debajo cuatro neuronas idénticas de verdad.
X = np.array([[-1., 1.], [-1., -1.], [1., 1.], [1., -1.]])
Y = np.array([[1.], [0.], [0.], [1.]])
B, eta, pasos = 4, 0.5, 1000
def entrena(escala, iguales=False):
rng = np.random.default_rng(0) # la misma semilla en todas
W1, W2 = rng.normal(size=(4, 2)) * escala, rng.normal(size=(1, 4)) * escala
if iguales: # cuatro copias de una misma neurona
W1, W2 = np.repeat(W1[:1], 4, axis=0), np.full((1, 4), W2[0, 0])
b1, b2 = np.zeros(4), np.zeros(1)
curva = []
for t in range(pasos + 1):
H1 = np.tanh(X @ W1.T + b1)
Y_hat = 1.0 / (1.0 + np.exp(-(H1 @ W2.T + b2)))
p = np.clip(Y_hat, 1e-12, 1.0 - 1e-12)
curva.append(-np.mean(Y * np.log(p) + (1.0 - Y) * np.log(1.0 - p)))
if t == pasos:
break
D2 = Y_hat - Y
D1 = (D2 @ W2) * (1.0 - H1 ** 2)
W1 -= eta * (D1.T @ X) / B
b1 -= eta * D1.sum(0) / B
W2 -= eta * (D2.T @ H1) / B
b2 -= eta * D2.sum(0) / B
return curva, int(((Y_hat >= 0.5) == (Y == 1.0)).sum()), W1
print("%7s %8s %8s %8s %9s %s"
% ("escala", "paso 0", "paso 50", "paso 200", "paso 1000", "aciertos"))
for escala in [0.0, 0.01, 0.1, 0.5, 1.0, 10.0]:
curva, aciertos, _ = entrena(escala)
print("%7.2f %8.4f %8.4f %8.4f %9.4f %d de 4"
% (escala, curva[0], curva[50], curva[200], curva[1000], aciertos))
# Y la simetría sin ceros: cuatro copias de una misma neurona, con pesos normales.
curva, aciertos, W1 = entrena(1.0, iguales=True)
print()
print("cuatro neuronas idénticas: pérdida %.4f -> %.4f, %d de 4"
% (curva[0], curva[-1], aciertos))
print("W(1) tras 1000 pasos:", np.round(W1, 4).tolist())
print("¿las cuatro filas siguen siendo la misma?", bool(np.allclose(W1, W1[0])))
La primera ejecución descarga el intérprete de Python (~15 MB). Después queda en la caché del navegador.
Las dos primeras filas de la tabla no se mueven de , que es : la red contesta a las cuatro frases al empezar y sigue contestando lo mismo mil pasos después. La segunda de las dos es la que enseña algo, porque su escala es y no cero: las cuatro neuronas sí son distintas. Lo que pasa es que cada capa recibe un gradiente multiplicado por la escala de la otra, y con dos factores pequeños todo avanza cientos de veces más despacio: esa red no está atascada, llega a las cuatro frases hacia el paso cien mil. Al otro extremo, la escala arranca en —confiadamente equivocada— y se recupera: con dos capas eso sale barato, y con profundidad deja de salirlo.
Las tres últimas líneas son la simetría sin ceros de por medio. Las cuatro neuronas empiezan con pesos normales, sólo que iguales entre sí, y mil pasos después siguen siendo la misma fila repetida cuatro veces. La pérdida ha bajado de a , que es exactamente lo que consigue una red que contesta lo mismo a todo: cuatro neuronas trabajando como una, y una no puede.
Comprueba tu intuición
Tres preguntas —por qué el cero congela la red, un gradiente de batch en NumPy y qué se puede leer en una curva de pérdida— y un desafío que es el bucle entero.
La red de la lección arranca con los cuatro pesos de y los ocho de puestos a cero, y mil pasos después sigue exactamente igual. ¿Por qué?
Así calcula la celda el gradiente de una capa a partir del error del batch. ¿Qué imprime?
import numpy as np
D = np.array([[1., -1.], [2., 0.]])
H = np.array([[1., 0., 2.], [0., 1., 1.]])
print((D.T @ H).shape, (D.T @ H / len(H))[0])
Marca todo lo que sea cierto sobre las curvas de pérdida que imprimen la segunda y la tercera celda.
Marca todas las opciones correctas. Se corrige todo o nada: no hay puntuación parcial.
Escribe el bucle entero para la red de esta lección: entradas, una capa oculta de neuronas con y una salida con sigmoide, sobre un batch de ejemplos.
gradientes(X, Y, W1, b1, W2, b2)devuelve los cuatro gradientes de en el orden(gW1, gb1, gW2, gb2), cada uno con la forma del parámetro que deriva.entrena(X, Y, W1, b1, W2, b2, eta, pasos)dapasospasos de descenso y devuelve(curva, W1, b1, W2, b2), dondecurvatienepasos + 1pérdidas: una antes de cada paso y una al final.
entrena no puede escribir en las matrices que recibe.
La primera comprobación descarga el intérprete de Python (~15 MB); después queda en la caché del navegador. Este desafío se resuelve mejor con un teclado físico: en el móvil puedes leerlo y volver luego.
La red acierta las cuatro frases que ha visto, y no hay una quinta. Eso no es un defecto del entrenamiento: es que aquí acertar y aprender son indistinguibles, porque con cuatro ejemplos memorizar la respuesta y entender la regla producen los mismos números. Nada de lo que imprime este bucle dice qué haría la red con no está tan bien, y el bucle no tiene forma de enterarse.
Queda además una deuda que la de los pesos ha dejado a la vista. Las dos coordenadas de entrada las inventé yo: «¿aparece no?» y «¿la palabra que valora es bien o mal?» son dos preguntas que alguien tuvo que formular después de leer las cuatro frases, y un texto cualquiera no llega en dos números. Llegaba, eso sí, en la bolsa de palabras del bloque anterior. Montar este mismo bucle sobre unos cientos de reseñas representadas así, medir por primera vez sobre reseñas que la red no ha visto y ver qué queda del acierto es la siguiente lección, el proyecto de clasificación de sentimiento.
Para profundizar2 fuentes · 1 paper, 1 interactivo
De dónde sale lo de esta lección, y dónde seguir si quieres más. Nada de aquí hace falta para continuar el curso.
- Understanding the difficulty of training deep feedforward neural networks
El artículo al que remite la lección al aplazar «qué escala» al bloque 3: fija la escala de inicialización a partir de la anchura de cada capa para que la señal ni sature ni se apague. La simetría no la trata; eso ya lo hace la lección.
- A Neural Network Playground
El bucle que acabas de escribir, con mandos: mueve la tasa de aprendizaje, las capas, las neuronas y la activación, y mira caer la pérdida. Trabaja sobre puntos en el plano, no sobre texto, y trae regularización ajena al curso.