Lección 2 de 10 · La red · 16 min
De neurona a capa
La capa como producto de matrices, y el batch
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 dos: de neurona a capa. En el episodio uno vimos que una neurona sola separa con una recta, y que para XOR hacían falta dos. Hoy ponemos varias neuronas juntas, descubrimos que eso es una multiplicación de matrices, y terminamos resolviendo XOR.
Lo que vamos a armar
- Capa: varias neuronas que leen las mismas entradas
- Pesos de la capa = una matriz W, una fila por neurona
- Batch: muchos ejemplos a la vez, en otra matriz X
- Dos capas en fila: XOR resuelto
El plan, en cuatro pasos. Una capa son varias neuronas que miran las mismas entradas, cada una con sus propios pesos. Si apilamos esos pesos, uno arriba del otro, forman una matriz: una tabla de números con una fila por neurona. Si apilamos también los ejemplos, procesamos un batch entero con una sola cuenta. Y con dos capas en fila, resolvemos el XOR que el perceptrón no pudo.
Parte 1 · Dos neuronas, mismas entradas
Arrancamos con la neurona del episodio uno y le ponemos una compañera al lado.
Dos neuronas, una por una
Las mismas tres entradas del episodio uno: uno, dos y tres. La neurona uno es la de siempre: pesos cero coma cinco, menos cero coma cinco y cero coma dos, sesgo cero coma cinco. La neurona dos tiene otros pesos: menos cero coma tres, cero coma uno y cero coma cuatro, y sesgo cero. Cada una hace su producto punto con x y suma su sesgo. La uno da cero coma seis, como ya sabíamos. La dos da uno coma uno. Dos neuronas, dos números. Con diez neuronas serían diez líneas iguales: eso pide a gritos una forma más compacta.
x = np.array([1.0, 2.0, 3.0]) # las mismas 3 entradas
w1, b1 = np.array([0.5, -0.5, 0.2]), 0.5 # neurona 1 (la del ep. 1)
w2, b2 = np.array([-0.3, 0.1, 0.4]), 0.0 # neurona 2
z1 = w1 @ x + b1
z2 = w2 @ x + b2
print("z1 =", round(z1, 2), " z2 =", round(z2, 2))
z1 = 0.6 z2 = 1.1
Apilar los pesos: una matriz
neurona 1: la del episodio uno
neurona 2: otros pesos, mismas entradas
fila i de W = pesos de la neurona i
las dos neuronas en una cuenta
Lo mismo, dibujado. La neurona uno, con sus números: da cero coma seis. La neurona dos, con las mismas entradas y otros pesos: da uno coma uno. Ahora apilamos los dos vectores de pesos como filas de una tabla. Esa tabla es la matriz W: dos filas, una por neurona, y tres columnas, una por entrada. Decimos que es de dos por tres. En el dibujo, elegir la fila dos es tomar los pesos de la neurona dos. Y la cuenta de toda la capa es W por x más b, donde b ahora es un vector con un sesgo por neurona. En el dibujo, mirá cómo la fila dos de W recorre la columna x, término a término, multiplica y suma, y llena la segunda celda de z. Matriz por vector: un producto punto por fila.
La capa en una línea
En NumPy. W es un array de dos filas por tres columnas: la fila cero son los pesos de la neurona uno, la fila uno los de la neurona dos. El sesgo es un vector de dos. Y la capa entera es una línea: W arroba x más b. Imprimimos las formas, lo que NumPy llama shape. W es de dos por tres, x tiene tres números, z tiene dos. Y z da cero coma seis y uno coma uno: lo mismo que neurona por neurona. Regla para no perderse: la cantidad de columnas de W tiene que coincidir con el largo de x.
W = np.array([[ 0.5, -0.5, 0.2], # fila 0 = neurona 1
[-0.3, 0.1, 0.4]]) # fila 1 = neurona 2
b = np.array([0.5, 0.0]) # un sesgo por neurona
z = W @ x + b # las dos de una vez
print("W:", W.shape, " x:", x.shape, " z:", z.shape)
print("z =", z.round(2))
W: (2, 3) x: (3,) z: (2,)
z = [0.6 1.1]
Una capa de dos neuronas
El dibujo de siempre. Las tres entradas. Y la capa: cada neurona recibe una línea desde cada entrada. Seis líneas, seis pesos: exactamente los seis números de W. Mirá cómo la señal cruza de izquierda a derecha: eso es el forward, la pasada hacia adelante. Los colores del dibujo son esquemáticos; los pesos reales están en la escena anterior. Esta pieza, en PyTorch, se llama capa lineal, o densa, porque todas las entradas se conectan con todas las neuronas.
Parte 2 · Muchos ejemplos a la vez: el batch
Hasta ahora, un ejemplo por vez. Las redes reales nunca trabajan así.
Por qué un batch
- Apilar ejemplos como filas de una matriz X
- Una sola multiplicación procesa todos
- La GPU hace miles de productos en paralelo
- El gradiente se promedia sobre el batch (ep. 7)
Un batch es un grupo de ejemplos que se procesan juntos. Los apilamos como filas de otra matriz, X: una fila por ejemplo. Así, toda la capa sobre todos los ejemplos es una sola multiplicación de matrices. Y eso es lo que mejor hace una GPU, graphics processing unit, la placa de video: miles de productos y sumas en paralelo. Por eso todo en deep learning se escribe como matrices. Además, cuando entrenemos, el gradiente se va a promediar sobre el batch. Eso lo vemos en el episodio siete.
Un batch de cuatro ejemplos
Cuatro ejemplos de tres entradas: X es de cuatro por tres. El primero es el uno, dos, tres de siempre. La capa: X por W transpuesta, más b. Transponer da vuelta W: de dos por tres pasa a tres por dos, y así las dimensiones encajan. Cuatro por tres, por tres por dos, da cuatro por dos: una fila por ejemplo, una columna por neurona. El sesgo se suma a cada fila; NumPy lo repite solo, y a eso le llama broadcasting. La activación se aplica a cada celda por separado. Y chequeamos que la fila cero coincida con lo de antes. Z tiene cuatro filas. La primera es cero coma seis, uno coma uno. En la fila dos aparece un menos cero coma dos, que la ReLU, rectified linear unit, convierte en cero. Y el chequeo da True.
Cada línea, su término:
X = np.array→ — 4 ejemplos × 3 entradasZ = X @ W.T + b→ — (4×3)·(3×2) = 4×2ReLU fila 2→ — la activación, celda por celdanp.allclose(Z[0]→ — la fila 0 es el caso de antes
X = np.array([[1.0, 2.0, 3.0], # ejemplo 0: el de antes
[0.0, 1.0, 0.0],
[2.0, 0.0, 1.0],
[1.0, 1.0, 1.0]]) # batch de 4 ejemplos
Z = X @ W.T + b # (4,3) @ (3,2) + (2,)
print("X:", X.shape, " W.T:", W.T.shape, " Z:", Z.shape)
print(Z.round(2))
print("ReLU fila 2:", np.maximum(0, Z[2]))
print("fila 0 == z de antes:", np.allclose(Z[0], W @ X[0] + b))
X: (4, 3) W.T: (3, 2) Z: (4, 2)
[[ 0.6 1.1]
[ 0. 0.1]
[ 1.7 -0.2]
[ 0.7 0.2]]
ReLU fila 2: [1.7 0. ]
fila 0 == z de antes: True
Qué es cada celda de Z
B ejemplos, m neuronas
ejemplo i, neurona j: un producto punto
pesos más un sesgo por neurona
Leamos la fórmula con cuidado. B es el tamaño del batch, y m la cantidad de neuronas. En el dibujo, mirá la fila tres de X recorriendo la columna dos de W transpuesta: llena la celda que cruza el ejemplo tres con la neurona dos. Esa celda es un producto punto, el mismo del episodio uno. En el dibujo, el ejemplo tres, dos, cero, uno, entra a la neurona dos: da menos cero coma dos, y la ReLU lo lleva a cero. Y cuántos números aprende una capa: n entradas por m neuronas, más m sesgos. Para la nuestra: tres por dos, seis pesos, más dos sesgos, ocho parámetros.
Z, el resultado del batch
Así queda Z, con los números reales. Cada fila es un ejemplo, cada columna una neurona. Leela por columnas: la neurona uno se enciende fuerte con el ejemplo tres, uno coma siete, y la neurona dos con el ejemplo uno, uno coma uno. Cada neurona responde a un patrón distinto de las entradas. Esa es la idea de fondo de una capa: varios detectores mirando lo mismo, y cada uno reacciona a algo diferente.
nn.Linear con un batch
Lo mismo con PyTorch. nn punto Linear de tres a dos. Ojo con el orden: se escribe entradas, salidas, pero el weight se guarda al revés, de salidas por entradas, igual que nuestra W. Copiamos nuestros pesos y sesgos. Le pasamos el batch entero de una vez: calcula X por weight transpuesta más bias. Y contamos sus parámetros. Weight de dos por tres, bias de dos, salida de cuatro por dos, idéntica a NumPy, y ocho parámetros, como contamos a mano.
capa = nn.Linear(3, 2) # 3 entradas, 2 neuronas
print("weight:", tuple(capa.weight.shape), " bias:", tuple(capa.bias.shape))
with torch.no_grad():
capa.weight.copy_(torch.tensor([[0.5, -0.5, 0.2],
[-0.3, 0.1, 0.4]]))
capa.bias.copy_(torch.tensor([0.5, 0.0]))
X = torch.tensor(X_np, dtype=torch.float32) # el batch de 4
Z = capa(X) # X @ weight.T + bias
print("Z:", tuple(Z.shape))
print("igual a NumPy:", np.allclose(Z.detach().numpy(),
X_np @ capa.weight.detach().numpy().T + [0.5, 0.0]))
print("parámetros:", sum(p.numel() for p in capa.parameters()))
weight: (2, 3) bias: (2,)
Z: (4, 2)
igual a NumPy: True
parámetros: 8
Masters & Luschi, 2018 — Revisiting Small Batch Training for Deep Neural Networks · arXiv:1804.07612
m = 2 … 32 · «The best performance has been consistently obtained for mini-batch sizes between m = 2 and m = 32»
¿De qué tamaño conviene el batch? No es obvio, y se discute. En este paper de Graphcore, Masters y Luschi entrenaron redes de imágenes con distintos tamaños, y el mejor resultado lo obtuvieron siempre con batches de entre dos y treinta y dos ejemplos, en contraste con trabajos que proponían batches de miles. Más grande es más rápido por paso en una GPU, pero no necesariamente aprende mejor. Volvemos a esto en el episodio siete.
Parte 3 · Dos capas en fila: XOR
La salida de una capa puede ser la entrada de otra. Con eso, la promesa del episodio uno.
XOR con tres neuronas, a mano
h₁: OR, alguna vale 1
h₂: AND, valen 1 las dos
salida: OR y no AND = XOR
Los pesos los elegimos a mano, para ver que se puede; aprenderlos es tema de los episodios cuatro y cinco. Los corchetes son el escalón del episodio uno. La neurona h uno suma las entradas y resta un medio: se enciende si alguna vale uno. Es un OR. En el dibujo, con la entrada uno, cero: z da cero coma cinco, y h uno vale uno. La h dos resta uno y medio: se enciende solo si valen uno las dos. Es un AND. Con uno, cero: menos cero coma cinco, y h dos vale cero. La salida mira h, no x: h uno menos h dos menos un medio. Se enciende si es OR pero no AND. En el dibujo: da cero coma cinco, y uno. Eso es XOR.
XOR en forma de matrices
Y ahora con todo lo de hoy junto. Las cuatro entradas posibles son un batch. W uno tiene dos filas: la neurona OR y la neurona AND, con sus sesgos. La neurona de salida: más uno, menos uno, y menos un medio. La hidden layer: X por W uno transpuesta, más el sesgo, y escalón. Se llama hidden layer porque no es ni la entrada ni la salida: queda escondida adentro de la red. Y la salida lee H. H tiene cuatro filas, una por entrada. Y la salida da cero, uno, uno, cero: XOR, resuelto con dos capas.
escalon = lambda z: (z > 0).astype(int)
X = np.array([[0, 0], [0, 1], [1, 0], [1, 1]]) # las 4 entradas: un batch
W1 = np.array([[1, 1], # neurona h1: OR
[1, 1]]) # neurona h2: AND
b1 = np.array([-0.5, -1.5])
w2, b2 = np.array([1, -1]), -0.5 # salida: OR y no AND
H = escalon(X @ W1.T + b1) # hidden layer: (4, 2)
y = escalon(H @ w2 + b2) # salida: (4,)
print("H =", H.tolist())
print("y =", y.tolist(), " (XOR: [0, 1, 1, 0])")
H = [[0, 0], [1, 0], [1, 0], [1, 1]]
y = [0, 1, 1, 0] (XOR: [0, 1, 1, 0])
La hidden layer mueve los puntos
Por qué funciona. Este plano no es el de las entradas: es el de la hidden layer, con h uno en horizontal y h dos en vertical. Los cuatro ejemplos se movieron. El cero, cero quedó en cero, cero. El uno, uno, en uno, uno. Y los dos verdes, el cero, uno y el uno, cero, cayeron en el mismo punto: uno, cero. Ahora una sola recta alcanza: la de la neurona de salida, h uno menos h dos igual a un medio. Los dos verdes, en un solo punto, del lado del uno. La hidden layer transformó el problema en uno que una neurona sí resuelve. Esa es la idea entera del deep learning.
Una red de verdad: dígitos
Para cerrar, una red con forma de red de verdad. Un batch de dieciséis dígitos escritos a mano, de scikit-learn: cada uno es una imagen de ocho por ocho, sesenta y cuatro píxeles. Una capa de sesenta y cuatro entradas a treinta y dos neuronas. Una ReLU. Y otra capa de treinta y dos a diez: un número por dígito, del cero al nueve. Los pesos son al azar; todavía no aprendió nada. Recorremos las capas y miramos la forma. Dieciséis por treinta y dos, con dos mil ochenta parámetros. La ReLU no tiene parámetros. Y dieciséis por diez, con trescientos treinta. Esto, capas lineales con activaciones en el medio, es un MLP, multilayer perceptron: el mismo bloque que en Alineación, episodio uno, vimos adentro de cada capa de Gemma.
X = torch.tensor(load_digits().data[:16], dtype=torch.float32) # 16 dígitos 8x8
red = nn.Sequential(nn.Linear(64, 32), # 64 píxeles -> 32 neuronas
nn.ReLU(),
nn.Linear(32, 10)) # 32 -> 10 (un número por dígito)
h = X
for capa in red:
h = capa(h)
n = sum(p.numel() for p in capa.parameters())
print(f"{capa.__class__.__name__:7} -> {tuple(h.shape)} parámetros: {n}")
Linear -> (16, 32) parámetros: 2080
ReLU -> (16, 32) parámetros: 0
Linear -> (16, 10) parámetros: 330
La red de dígitos, en formas
La misma red, como tabla de formas. Entra un batch de dieciséis por sesenta y cuatro. La primera capa tiene un weight de treinta y dos por sesenta y cuatro: salidas por entradas. Sus parámetros: sesenta y cuatro por treinta y dos, más treinta y dos sesgos. La ReLU no cambia la forma. Y la última deja dieciséis por diez. Fijate que el tamaño del batch nunca aparece en los pesos: la misma red procesa uno o mil ejemplos.
| capa | weight | salida (batch 16) | parámetros |
|---|---|---|---|
| entrada | — | 16 × 64 | 0 |
| Linear(64, 32) | 32 × 64 | 16 × 32 | 64·32 + 32 = 2080 |
| ReLU | — | 16 × 32 | 0 |
| Linear(32, 10) | 10 × 32 | 16 × 10 | 32·10 + 10 = 330 |
Resumiendo
- Capa: , una fila de por neurona
- Batch: , una fila de por ejemplo
- nn.Linear(n, m): weight , parámetros
- XOR: una hidden layer mueve los puntos y una recta alcanza
- MLP = capas lineales con activaciones en el medio
- Próximo: por qué la activación es imprescindible
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: De neurona a capa
Conectando con el tutor…