Saltar al contenido
← Deep learning a secas

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.

python
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))
text
z1 = 0.6   z2 = 1.1

Apilar los pesos: una matriz

z1=w1⊤x+b1=0,6z_1 = w_1^\top x + b_1 = 0{,}6

neurona 1: la del episodio uno

z2=w2⊤x+b2=1,1z_2 = w_2^\top x + b_2 = 1{,}1

neurona 2: otros pesos, mismas entradas

W=[w1⊤w2⊤]=[0,5−0,50,2−0,30,10,4]W = \begin{bmatrix} w_1^\top \\ w_2^\top \end{bmatrix} = \begin{bmatrix} 0{,}5 & -0{,}5 & 0{,}2 \\ -0{,}3 & 0{,}1 & 0{,}4 \end{bmatrix}

fila i de W = pesos de la neurona i

z=Wx+b∈R2z = W x + b \in \mathbb{R}^{2}

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.

python
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))
text
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 → X∈R4×3X \in \mathbb{R}^{4 \times 3} — 4 ejemplos × 3 entradas
  • Z = X @ W.T + b → Z=XW⊤+bZ = X W^\top + b — (4×3)·(3×2) = 4×2
  • ReLU fila 2 → ReLU(Z)ij=max⁡(0,Zij)\mathrm{ReLU}(Z)_{ij} = \max(0, Z_{ij}) — la activación, celda por celda
  • np.allclose(Z[0] → Z0,:=Wx0+bZ_{0,:} = W x_0 + b — la fila 0 es el caso de antes
python
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))
text
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

Z=X W⊤+b  ∈  RB×mZ = X\,W^\top + b \;\in\; \mathbb{R}^{B \times m}

B ejemplos, m neuronas

Zij=xi⊤wj+bjZ_{ij} = x_i^\top w_j + b_j

ejemplo i, neurona j: un producto punto

#paraˊmetros=n⋅m+m\#\text{parámetros} = n \cdot m + m

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.

python
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()))
text
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

h1=[ x1+x2−0,5>0 ]h_1 = [\,x_1 + x_2 - 0{,}5 > 0\,]

h₁: OR, alguna vale 1

h2=[ x1+x2−1,5>0 ]h_2 = [\,x_1 + x_2 - 1{,}5 > 0\,]

h₂: AND, valen 1 las dos

y=[ h1−h2−0,5>0 ]y = [\,h_1 - h_2 - 0{,}5 > 0\,]

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.

python
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])")
text
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.

python
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}")
text
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.

capaweightsalida (batch 16)parámetros
entrada—16 × 640
Linear(64, 32)32 × 6416 × 3264·32 + 32 = 2080
ReLU—16 × 320
Linear(32, 10)10 × 3216 × 1032·10 + 10 = 330

Resumiendo

  • Capa: z=Wx+bz = Wx + b, una fila de WW por neurona
  • Batch: Z=XW⊤+bZ = XW^\top + b, una fila de ZZ por ejemplo
  • nn.Linear(n, m): weight m×nm \times n, n⋅m+mn \cdot m + m 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

Entra para calificar

¿Terminaste la lección?

Márcala para sumar +50 XP y mantener tu racha.