El forward pass en forma matricial

El forward pass en forma matricial

30 min de lectura

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 φ\varphi 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 104=4010 \cdot 4 = 40 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.

Tres rectángulos en fila, separados por un signo de multiplicar y un signo de igual. El primero, etiquetado X, está dividido en seis filas y lleva debajo la forma B por d cero; su tercera fila está sombreada en verde y marcada a la izquierda con la letra i. El segundo, etiquetado W uno transpuesta, está dividido en cuatro columnas y lleva debajo la forma d cero por d uno; su segunda columna está sombreada en verde y marcada debajo con la letra j. El tercero, etiquetado Z uno, tiene las seis filas del primero y las cuatro columnas del segundo, y lleva debajo la forma B por d uno; de él sólo está sombreada la casilla donde se cruzan la tercera fila y la segunda columna. La d cero aparece en verde en las dos primeras formas y no aparece en la tercera.
La dimensión que comparten los dos factores es la única que desaparece del resultado. Cada casilla de la derecha es una neurona opinando sobre un ejemplo, y meter un ejemplo más añade una fila a la izquierda y otra a la derecha sin tocar los pesos.

Una capa entera en un solo producto

Fijemos una capa cualquiera y llamemos dld_l a su anchura, el número de neuronas que tiene. La capa ll recibe dl1d_{l-1} números y entrega dld_l, de modo que sus pesos son una matriz W(l)Rdl×dl1\mathbf{W}^{(l)} \in \mathbb{R}^{d_l \times d_{l-1}} y sus sesgos un vector b(l)Rdl\mathbf{b}^{(l)} \in \mathbb{R}^{d_l}, uno por neurona. Con un solo ejemplo entrando, la lección anterior ya escribió el resultado:

z(l)=W(l)h(l1)+b(l),h(l)=φ(z(l)),\mathbf{z}^{(l)} = \mathbf{W}^{(l)}\mathbf{h}^{(l-1)} + \mathbf{b}^{(l)}, \qquad \mathbf{h}^{(l)} = \varphi\left(\mathbf{z}^{(l)}\right),

con h(l1)Rdl1\mathbf{h}^{(l-1)} \in \mathbb{R}^{d_{l-1}}, con z(l),h(l)Rdl\mathbf{z}^{(l)}, \mathbf{h}^{(l)} \in \mathbb{R}^{d_l} y con φ\varphi 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 00 que no calcula nada, h(0)=x\mathbf{h}^{(0)} = \mathbf{x}. Eso hace de d0d_0 la dimensión de la entrada y no la anchura de ninguna capa de neuronas. Con las reseñas de arriba, h(0)\mathbf{h}^{(0)} son las ocho cuentas de la bolsa de palabras y d0=8d_0 = 8.

Lo que hace falta ver es que dentro de ese producto no hay nada nuevo. Desarrolla la coordenada jj:

zj(l)=k=1dl1Wjk(l)hk(l1)+bj(l).z^{(l)}_j = \sum_{k=1}^{d_{l-1}} \mathbf{W}^{(l)}_{jk}\, h^{(l-1)}_k + b^{(l)}_j.

Esa suma es la neurona de la lección sobre la neurona artificial, con la fila jj de W(l)\mathbf{W}^{(l)} haciendo de vector de pesos. El producto de matrices no añade ninguna operación: agrupa las dld_l 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í, dldl1d_l \cdot d_{l-1} 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 BB al tamaño del batch y guardemos los BB ejemplos en una matriz XRB×d0\mathbf{X} \in \mathbb{R}^{B \times d_0}, 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, H(l)RB×dl\mathbf{H}^{(l)} \in \mathbb{R}^{B \times d_l}, la fila ii de H(l1)\mathbf{H}^{(l-1)} es el ejemplo ii tumbado: es (hi(l1))\left(\mathbf{h}^{(l-1)}_i\right)^{\top}, 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 ii, recordando que transponer un producto le da la vuelta al orden de los factores:

(zi(l))=(W(l)hi(l1))+(b(l))=(hi(l1))(W(l))+(b(l)),\left(\mathbf{z}^{(l)}_i\right)^{\top} = \left(\mathbf{W}^{(l)}\mathbf{h}^{(l-1)}_i\right)^{\top} + \left(\mathbf{b}^{(l)}\right)^{\top} = \left(\mathbf{h}^{(l-1)}_i\right)^{\top}\left(\mathbf{W}^{(l)}\right)^{\top} + \left(\mathbf{b}^{(l)}\right)^{\top},

que es una igualdad entre filas: a la izquierda, la fila ii de Z(l)\mathbf{Z}^{(l)}; a la derecha, la fila ii de H(l1)\mathbf{H}^{(l-1)} multiplicada por la misma matriz que las demás. Apilar las BB filas es entonces un solo producto:

Z(l)=H(l1)(W(l))+1B(b(l)),H(l)=φ(Z(l)),\mathbf{Z}^{(l)} = \mathbf{H}^{(l-1)}\left(\mathbf{W}^{(l)}\right)^{\top} + \mathbf{1}_B\left(\mathbf{b}^{(l)}\right)^{\top}, \qquad \mathbf{H}^{(l)} = \varphi\left(\mathbf{Z}^{(l)}\right),

con Z(l),H(l)RB×dl\mathbf{Z}^{(l)}, \mathbf{H}^{(l)} \in \mathbb{R}^{B \times d_l} y con 1BRB\mathbf{1}_B \in \mathbb{R}^{B} 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. Z(l)\mathbf{Z}^{(l)} tiene BB filas y b(l)\mathbf{b}^{(l)} tiene dld_l coordenadas: sumarlos no es una operación entre matrices, así que lo que hay escrito es el producto 1B(b(l))\mathbf{1}_B\left(\mathbf{b}^{(l)}\right)^{\top}, la misma fila de sesgos repetida BB 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 (k,j)(k, j) de (W(l))\left(\mathbf{W}^{(l)}\right)^{\top} es la casilla (j,k)(j, k) de W(l)\mathbf{W}^{(l)}, así que por la definición del producto de matrices

[H(l1)(W(l))]ij=k=1dl1Hik(l1)Wjk(l),\left[\mathbf{H}^{(l-1)}\left(\mathbf{W}^{(l)}\right)^{\top}\right]_{ij} = \sum_{k=1}^{d_{l-1}} \mathbf{H}^{(l-1)}_{ik}\, \mathbf{W}^{(l)}_{jk},

donde Hik(l1)\mathbf{H}^{(l-1)}_{ik} es la coordenada kk del ejemplo ii. El sumando del sesgo aporta

[1B(b(l))]ij=1bj(l)=bj(l),\left[\mathbf{1}_B\left(\mathbf{b}^{(l)}\right)^{\top}\right]_{ij} = 1 \cdot b^{(l)}_j = b^{(l)}_j,

el mismo número para toda fila ii, que es lo que significa «un sesgo por neurona». Sumando las dos, la casilla (i,j)(i, j) de Z(l)\mathbf{Z}^{(l)} es la preactivación de la neurona jj para el ejemplo ii: la suma de la sección anterior, término a término. El índice kk es el que se recorre y desaparece; ii y jj 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 LL el número de capas —la de salida incluida— y sean d0,d1,,dLd_0, d_1, \dots, d_L las anchuras de la cadena, empezando por la de la entrada. El forward pass es esta recurrencia:

H(0)=X,H(l)=φ(H(l1)(W(l))+1B(b(l)))para l=1,,L,\mathbf{H}^{(0)} = \mathbf{X}, \qquad \mathbf{H}^{(l)} = \varphi\left(\mathbf{H}^{(l-1)}\left(\mathbf{W}^{(l)}\right)^{\top} + \mathbf{1}_B\left(\mathbf{b}^{(l)}\right)^{\top}\right) \quad \text{para } l = 1, \dots, L,

y lo que sale de la última capa es la predicción de la red para los BB ejemplos a la vez, Y^=H(L)RB×dL\hat{\mathbf{Y}} = \mathbf{H}^{(L)} \in \mathbb{R}^{B \times d_L}. 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: H(l1)\mathbf{H}^{(l-1)} tiene dl1d_{l-1} columnas y W(l)\mathbf{W}^{(l)} también, y si las dos anchuras no coinciden el producto no existe. Las anchuras intermedias las eliges tú; d0d_0 te la da la representación de la entrada y dLd_L, cuántas respuestas quieres. La segunda es que BB 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:

l=1L(dldl1+dl).\sum_{l=1}^{L}\left(d_l \cdot d_{l-1} + d_l\right).

Para la red de reseñas de abajo, con d0=8d_0 = 8, d1=4d_1 = 4 y d2=1d_2 = 1, son 4141 números. Cambia el vocabulario por uno de 2000020\,000 entradas y la capa oculta por una de 128128 neuronas, y son 25602572\,560\,257: casi todos en W(1)\mathbf{W}^{(1)}, ninguno en función de BB.

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 time

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))
numpy

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 101410^{-14}, 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 40964\,096 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 Z\mathbf{Z} y el producto sin sesgo es la misma terna 0.761-0.761, 3.2213.221, 0.2210.221 en la fila 00 y en la fila 127127, que es 1Bb\mathbf{1}_B\mathbf{b}^{\top} 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 00 y 11. Ejecútala y quédate con la columna de formas y con la última línea.

import numpy as np

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")
numpy

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 1010 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 8418 \to 4 \to 1 al ritmo de las anchuras. La red tiene los 4141 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 0.50.5 —la mayor es 0.3570.357, 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 300300 coordenadas, tiene una capa oculta de 128128 neuronas y una capa de salida de 22. ¿Cuántos números guarda en total, entre pesos y sesgos?

Se acepta un margen de ±0.

Para un solo ejemplo, la capa es W(l)h(l1)\mathbf{W}^{(l)}\mathbf{h}^{(l-1)}; para un batch entero es H(l1)(W(l))\mathbf{H}^{(l-1)}\left(\mathbf{W}^{(l)}\right)^{\top}. ¿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 LL capas encadena H(l)=φ(H(l1)(W(l))+1B(b(l)))\mathbf{H}^{(l)} = \varphi\left(\mathbf{H}^{(l-1)}\left(\mathbf{W}^{(l)}\right)^{\top} + \mathbf{1}_B\left(\mathbf{b}^{(l)}\right)^{\top}\right) partiendo de H(0)=X\mathbf{H}^{(0)} = \mathbf{X}. Marca todo lo que sea cierto.

Marca todas las opciones correctas. Se corrige todo o nada: no hay puntuación parcial.

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. H tiene forma (B,din)(B,\, d_{\text{in}}), W tiene forma (dout,din)(d_{\text{out}},\, d_{\text{in}}) —una fila por neurona— y b tiene forma (dout,)(d_{\text{out}},); la salida tiene forma (B,dout)(B,\, d_{\text{out}}).
  • forward(X, capas, phi) encadena una lista de pares (W, b) desde X, aplicando phi a la salida de cada capa.

Ninguna de las dos puede escribir en los arrays 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 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 X\mathbf{X} en Y^\hat{\mathbf{Y}} 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 0.4990.499 que con una de 0.0360.036, 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 Y^\hat{\mathbf{Y}} 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.