El forward pass en forma matricial
30 min read
La red de la lección anterior, sobre XOR y las capas ocultas, quedó completa y sin escribir. Completa porque no le falta ninguna pieza: neuronas agrupadas en capas, cada una con sus pesos y su sesgo, y una en medio que impide que apilarlas se contraiga en una neurona sola. Sin escribir porque allí cada preactivación se calculó por separado, y una colección de cuentas sueltas no es una fórmula: no se desarrolla sobre ella y no se programa. El recorrido que va de la entrada hasta la salida tiene un nombre —el forward pass— y tiene una forma en la que se escribe siempre. Esta lección es esa forma.
Ponle números antes de escribirla. Toma las diez reseñas de la lección sobre la neurona artificial, con aquel vocabulario de ocho entradas. Lo que entra en la red no es la reseña sino su bolsa de palabras: un vector de ocho coordenadas, una por entrada del vocabulario, con las veces que esa entrada aparece en la reseña. Eso es todo lo que la red va a leer. Pon encima una capa oculta de cuatro neuronas: cada una mira esas ocho coordenadas y devuelve un número, de modo que las diez reseñas dan preactivaciones ocultas, cada una de ellas una suma de ocho productos más un sesgo. De una en una son cuarenta cuentas, o dos bucles encajados si prefieres que las haga el ordenador. Las cuarenta caben en un solo producto de matrices, y la capa siguiente vuelve a salir de otro igual. Lo que hay que fijar es qué multiplica a qué, y por dónde entran las diez reseñas. Porque van a entrar juntas: un grupo de ejemplos que atraviesan la red a la vez es un batch, y el de esta lección tiene diez.
Ese producto no es una abreviatura de los dos bucles: es el mismo cálculo con la contabilidad metida en las formas de los arrays. Mira dónde acaba cada número.
Una capa entera en un solo producto
Fijemos una capa cualquiera y llamemos a su anchura, el número de neuronas que tiene. La capa recibe números y entrega , de modo que sus pesos son una matriz y sus sesgos un vector , uno por neurona. Con un solo ejemplo entrando, la lección anterior ya escribió el resultado:
con , con y con aplicada coordenada a coordenada. Los vectores son columnas, como todos los del curso, y por eso el producto está escrito en ese orden.
Cada capa se apoya en la anterior, así que la cadena necesita un principio. La primera capa no tiene ninguna delante: lo que recibe es el ejemplo tal cual, y el convenio es contar la entrada como una capa que no calcula nada, . Eso hace de la dimensión de la entrada y no la anchura de ninguna capa de neuronas. Con las reseñas de arriba, son las ocho cuentas de la bolsa de palabras y .
Lo que hace falta ver es que dentro de ese producto no hay nada nuevo. Desarrolla la coordenada :
Esa suma es la neurona de la lección sobre la neurona artificial, con la fila de haciendo de vector de pesos. El producto de matrices no añade ninguna operación: agrupa las sumas que ya había, y su única aportación es que ahora no hay que escribirlas. La cuenta de lo que cuesta también sale de ahí, productos por ejemplo, y esa cifra crece con el tamaño de la capa aunque el número de líneas se quede en una.
El batch entra por la izquierda, y por eso aparece la transpuesta
Ahora los ejemplos. Llamemos al tamaño del batch y guardemos los ejemplos en una matriz , con un ejemplo en cada fila. Ese orden, el batch primero, es el del curso entero y el que usa NumPy. Con la misma disposición para lo que sale de cada capa, , la fila de es el ejemplo tumbado: es , con su transpuesta, porque el vector es una columna y la fila de una matriz no lo es.
Esa transpuesta es la que se propaga, y con ella sale todo lo demás. Transpón la ecuación de arriba para el ejemplo , recordando que transponer un producto le da la vuelta al orden de los factores:
que es una igualdad entre filas: a la izquierda, la fila de ; a la derecha, la fila de multiplicada por la misma matriz que las demás. Apilar las filas es entonces un solo producto:
con y con
el vector de unos. Ese último factor merece una frase, porque es
lo único de la fórmula que no está en el código. tiene filas y
tiene coordenadas: sumarlos no es una operación entre matrices, así que lo
que hay escrito es el producto , la misma fila de
sesgos repetida veces. NumPy hace esa repetición él solo cuando escribes + b, sin construir
la matriz, y por eso conviene haber visto una vez lo que está haciendo por debajo.
La comprobación casilla a casilla
Que el producto contiene las mismas sumas de antes se ve mirando una casilla cualquiera. La casilla de es la casilla de , así que por la definición del producto de matrices
donde es la coordenada del ejemplo . El sumando del sesgo aporta
el mismo número para toda fila , que es lo que significa «un sesgo por neurona». Sumando las dos, la casilla de es la preactivación de la neurona para el ejemplo : la suma de la sección anterior, término a término. El índice es el que se recorre y desaparece; y son los que sobreviven, y son las dos dimensiones del resultado.
De la entrada a la salida, una capa detrás de otra
Con una capa resuelta, la red es la repetición. Sea el número de capas —la de salida incluida— y sean las anchuras de la cadena, empezando por la de la entrada. El forward pass es esta recurrencia:
y lo que sale de la última capa es la predicción de la red para los ejemplos a la vez, . Esa cadena es todo lo que calcula un perceptrón multicapa (multilayer perceptron, MLP): no hay más pasos, y los de más adelante en el bloque no añadirán ninguno, sólo cambiarán los números que hay dentro de las matrices.
Dos cosas se leen directamente en las formas. La primera es la única condición que hay que respetar al diseñar una red: tiene columnas y también, y si las dos anchuras no coinciden el producto no existe. Las anchuras intermedias las eliges tú; te la da la representación de la entrada y , cuántas respuestas quieres. La segunda es que no aparece en ninguna matriz de pesos. Cuántos ejemplos entren a la vez es una decisión de quien ejecuta la red, no una propiedad de la red, y de ahí sale el recuento de lo que ocupa:
Para la red de reseñas de abajo, con , y , son números. Cambia el vocabulario por uno de entradas y la capa oculta por una de neuronas, y son : casi todos en , ninguno en función de .
El bucle, el producto y la red de reseñas en NumPy
La primera celda pone las dos versiones frente a frente sobre una capa de treinta y dos neuronas: el doble bucle que se sigue de la definición, y el producto. Ejecútala y mira tres cosas: si dan lo mismo, cuánto tarda cada una, y qué pasa cuando el producto se escribe sin transponer.
import numpy as np
np.random.seed(0)
# --- Una capa de 32 neuronas sobre un batch de 128 ejemplos de 64 coordenadas.
B, d0, d1 = 128, 64, 32
X = np.random.randn(B, d0)
W = np.random.randn(d1, d0)
b = np.random.randn(d1)
print("X", X.shape, " W", W.shape, " b", b.shape, "->", B * d1, "preactivaciones")
sin_sesgo = X @ W.T # se usa abajo; de paso calienta la rutina compilada
# 1. Neurona a neurona y ejemplo a ejemplo, como está escrita la definición.
t0 = time.perf_counter()
Z_bucle = np.zeros((B, d1))
for i in range(B):
for j in range(d1):
Z_bucle[i, j] = W[j] @ X[i] + b[j]
t1 = time.perf_counter()
# 2. El mismo cálculo, un producto.
Z = X @ W.T + b
t2 = time.perf_counter()
print("Z", Z.shape, " ¿los mismos números?", bool(np.allclose(Z, Z_bucle)))
print("máxima diferencia:", "%.1e" % np.abs(Z - Z_bucle).max())
print("bucle: %8.2f ms" % ((t1 - t0) * 1e3))
print("producto: %8.2f ms" % ((t2 - t1) * 1e3))
print("factor: %8.0f" % ((t1 - t0) / (t2 - t1)))
print()
# 3. La orientación no se negocia: sin transponer, el producto no existe.
try:
X @ W
except ValueError as e:
print("X @ W ->", e)
print()
# 4. El sesgo se reparte por columnas: uno por neurona, el mismo en las 128 filas.
print("b, 3 primeras: ", np.round(b[:3], 3))
print("Z - X @ W.T, fila 0: ", np.round((Z - sin_sesgo)[0, :3], 3))
print("Z - X @ W.T, fila 127: ", np.round((Z - sin_sesgo)[-1, :3], 3))
La primera ejecución descarga el intérprete de Python (~15 MB). Después queda en la caché del navegador.
Las dos versiones coinciden hasta el orden de , que es lo que separa a dos maneras de sumar los mismos números en coma flotante. Obtenerlas no cuesta lo mismo: el producto tarda entre uno y dos órdenes de magnitud menos, y el factor exacto depende de la máquina y del navegador. De dónde sale ese factor no depende de ninguno de los dos. No es que el producto haga menos operaciones —hace exactamente las mismas—, sino que las vueltas del bucle las da Python una a una, mientras el producto las da dentro de una rutina compilada sin volver a pasar por el intérprete.
Los dos últimos experimentos son las dos trampas de forma. X @ W no existe, y NumPy lo dice nombrando
las dos dimensiones que no encajan; y la diferencia entre y el producto sin sesgo es
la misma terna , , en la fila y en la fila , que es
hecho visible.
La segunda celda monta la red entera sobre las diez reseñas: ocho entradas, cuatro neuronas ocultas con ReLU (rectified linear unit) y una de salida con sigmoide, para que lo que entregue sea un número entre y . Ejecútala y quédate con la columna de formas y con la última línea.
np.random.seed(0)
# --- Las diez reseñas de siempre y su mismo vocabulario de ocho entradas.
resenas = [
"la película es divertida y la recomiendo",
"la película es lenta y aburrida",
"buena película la recomiendo",
"mala película muy lenta",
"divertida y buena",
"la película es mala",
"aburrida y lenta",
"la recomiendo",
"buena película pero lenta",
"divertida y la recomiendo",
]
etiquetas = np.array([1, 0, 1, 0, 1, 0, 0, 1, 0, 1])
V = ["aburrida", "buena", "divertida", "la", "lenta", "mala", "película", "recomiendo"]
X = np.array([[texto.split().count(e) for e in V] for texto in resenas], dtype=float)
# Ocho entradas -> cuatro neuronas ocultas -> una de salida. Los pesos, al azar.
d0, d1, d2 = len(V), 4, 1
W1 = np.random.randn(d1, d0) * 0.5
b1 = np.zeros(d1)
W2 = np.random.randn(d2, d1) * 0.5
b2 = np.zeros(d2)
H0 = X # (B, d0)
Z1 = H0 @ W1.T + b1 # (B, d1)
H1 = np.maximum(Z1, 0.0) # ReLU, coordenada a coordenada
Z2 = H1 @ W2.T + b2 # (B, d2)
Y = 1.0 / (1.0 + np.exp(-Z2)) # sigmoide en la capa de salida
for nombre, A in [("H(0)", H0), ("Z(1)", Z1), ("H(1)", H1), ("Z(2)", Z2), ("Y", Y)]:
print(nombre, A.shape)
print("parámetros de la red:", W1.size + b1.size + W2.size + b2.size)
print()
for texto, y in zip(resenas, Y[:, 0]):
print(texto.ljust(41), "%.3f" % y)
print()
print("aciertos:", int(((Y[:, 0] >= 0.5) == (etiquetas == 1)).sum()), "de 10")
La primera ejecución descarga el intérprete de Python (~15 MB). Después queda en la caché del navegador.
Cinco líneas de código para la red entera, y en la columna de formas está la razón: el de la primera dimensión no vuelve a aparecer en ningún sitio y llega intacto hasta el final, mientras la segunda dimensión va cambiando al ritmo de las anchuras. La red tiene los parámetros de la cuenta de arriba.
Y ahora la última línea, que es la que importa. Cinco aciertos de diez. Las diez salidas están por
debajo de —la mayor es , para divertida y buena—, así que la red contesta
que no a las diez reseñas, y acierta exactamente las cinco que eran negativas. Es la puntuación de
quien no lee nada y dice que no siempre. Los pesos salieron de np.random.randn, y no hay ninguna
razón para que fueran otra cosa.
Comprueba tu intuición
Cuatro preguntas —cuántos números guarda una red, de dónde sale la transpuesta, qué hace NumPy con el sesgo y qué fija la cadena— y un desafío que es el forward pass completo.
Una red recibe vectores de coordenadas, tiene una capa oculta de neuronas y una capa de salida de . ¿Cuántos números guarda en total, entre pesos y sesgos?
A margin of ±0 is accepted.
Para un solo ejemplo, la capa es ; para un batch entero es . ¿De dónde sale esa transpuesta?
Esta es la capa de la lección sobre un batch de tres ejemplos. ¿Qué imprime?
import numpy as np
H = np.ones((3, 4))
W = np.ones((2, 4))
b = np.array([0.5, -0.5])
Z = H @ W.T + b
print(Z.shape, Z[0])
Una red de capas encadena partiendo de . Marca todo lo que sea cierto.
Select every correct option. This is graded all-or-nothing: there is no partial credit.
Escribe el forward pass en su forma matricial, para un batch entero y sin recorrer los ejemplos uno a uno:
capa(H, W, b)devuelve la preactivación de una capa.Htiene forma ,Wtiene forma —una fila por neurona— ybtiene forma ; la salida tiene forma .forward(X, capas, phi)encadena una lista de pares(W, b)desdeX, aplicandophia la salida de cada capa.
Ninguna de las dos puede escribir en los arrays que recibe.
The first run downloads the Python interpreter (~15 MB); after that it stays in the browser cache. This challenge is much easier to solve on a physical keyboard: on a phone, read it and come back later.
La red ya sabe contestar, a un ejemplo o a mil, y lo que ha contestado es inservible. Esas dos cosas caben juntas porque el forward pass no contiene ninguna noción de acierto: es una cadena de productos que transforma en y que habría corrido exactamente igual con los pesos buenos, si alguien supiera cuáles son. Para corregirlos hay que empezar por medirlos, y medir no es contar aciertos. La cuenta de la última línea vale lo mismo con una salida de que con una de , y no se mueve cuando un peso cambia un poco: no distingue entre equivocarse por poco y equivocarse del todo, que es justo lo único que sirve para decidir hacia dónde mover un peso.
Hace falta un número que sí lo distinga. Uno solo para todo el batch, que crezca cuando se aleja de las etiquetas, que baje cuando se acerca, y que cambie de forma continua para que tenga derivada. Cómo se construye ese número, por qué el primer candidato que se le ocurre a cualquiera —elevar al cuadrado la diferencia— es el correcto para unas tareas y una mala idea para clasificar, y qué se usa en su lugar, es la siguiente lección, sobre funciones de pérdida: el error cuadrático medio y la entropía cruzada.