fast.ai · Practical Deep Learning for Coders · Lección 8 (última de la Parte 1)
Convoluciones: qué hay realmente dentro de una CNN
Usaste vision_learner con ResNet desde la Lección 1 sin mirar adentro. Esta lección cierra ese
círculo: qué es literalmente una convolución, con las manos, sin ninguna abstracción de por medio.
Antes de leerlección 3 §5, la red de dos capas
🎯 Al terminar vas a poder
- Calcular a mano una convolución 3×3 sobre una ventana de píxeles y explicar por qué un kernel fijo detecta bordes.
- Predecir el shape de salida de cualquier
Conv2da partir de padding, stride y kernel, y contar sus parámetros. - Escribir una CNN chica en PyTorch, leer
learn.summary()y reconocer el receptive field que crece con cada capa stride-2. - Explicar qué hace batch normalization en entrenamiento y en evaluación, y por qué permite learning rates altos.
- Diagnosticar los errores típicos de shapes (HWC vs CHW, canales, encogimiento sin padding) antes de que el entrenamiento falle en silencio.
1. Una convolución, con las manos
Una convolución detecta features visuales usando solo multiplicación y suma. Se desliza un kernel chico (típicamente 3×3) sobre cada bloque de la imagen: se multiplica elemento a elemento y se suma el resultado. El kernel de detección de bordes horizontales del libro:
top_edge = tensor([[-1,-1,-1],
[ 0, 0, 0],
[ 1, 1, 1]]).float()
def apply_kernel(row, col, kernel):
return (im3_t[row-1:row+2,col-1:col+2] * kernel).sum()
rng = range(1, 27) # 28x28 -> 26x26: el borde de 1 píxel no tiene ventana completa
top_edge3 = tensor([[apply_kernel(i, j, top_edge) for j in rng] for i in rng])
Si la ventana 3×3 se numera fila por fila como a1..a9, este kernel calcula
-a1-a2-a3+a7+a8+a9: si la fila de arriba y la de abajo son igual de oscuras, los términos se
cancelan (≈0); si abajo es más brillante que arriba, da un número alto. Así el filtro detecta,
literalmente, bordes horizontales de oscuro a claro — invirtiendo filas se detecta lo opuesto, y usando
columnas en vez de filas se detectan bordes verticales.
Un ejemplo con números: una ventana tomada del borde superior de un trazo, con fondo negro arriba y
tinta blanca abajo, se ve como [[0,0,0],[0,0,0],[255,255,255]] → -0-0-0+255+255+255 =
765. La misma ventana tomada del borde inferior del trazo (tinta arriba, fondo abajo) da
-765. Y en el interior del trazo, donde las tres filas son 255, da -765+765 = 0.
Por eso, aplicado sobre un "3" de MNIST, el mapa de activación de top_edge se ve así: las
barras horizontales del dígito aparecen como una línea brillante a lo largo de su borde superior y una
línea oscura (valores negativos) a lo largo de su borde inferior, el interior de los trazos queda gris
neutro como el fondo, y los tramos verticales del dígito casi desaparecen — no hay cambio entre fila y
fila ahí. El kernel left_edge (columnas en vez de filas) muestra exactamente lo
complementario. Repitiendo esto sobre toda la grilla de coordenadas se obtiene el mapa de activación
completo — sin ningún parámetro aprendido todavía, es puramente el kernel fijo aplicado a mano.
📐 Convolución formal vs. Correlación Cruzada en Deep Learning
En matemáticas y procesamiento de señales, la convolución estricta exige una inversión espacial de 180° del kernel ():
Sin embargo, librerías como PyTorch (torch.nn.Conv2d) y cuDNN implementan en realidad correlación cruzada (cross-correlation):
Dado que los pesos de se optimizan mediante descenso por gradiente, la red aprende directamente la orientación requerida sin necesidad de voltear la matriz en cada pasada hacia adelante.
Ecuación de dimensión espacial de salida:
Conteo analítico de parámetros: .
2. Padding y stride
Con una imagen h×w y un kernel ks×ks, solo caben (h-2)×(w-2)
ventanas para un kernel 3×3 — la salida se encoge en cada capa y las esquinas nunca se tocan.
Padding agrega píxeles extra (normalmente ceros) alrededor de la imagen para preservar el
tamaño; con kernel impar, el padding necesario por lado es ks//2. Stride
mueve el kernel más de un píxel por paso — stride-2 reduce la resolución espacial a la mitad, una
alternativa a usar pooling. El tamaño de salida por dimensión sigue esta fórmula:
(n + 2*pad - ks)//stride + 1
Probala con los casos que importan: 28 píxeles, kernel 3, sin padding, stride 1 →
(28+0-3)//1+1 = 26 (el encogimiento de §1). Con padding 1 → (28+2-3)//1+1 = 28,
tamaño preservado. Con padding 1 y stride 2 → (28+2-3)//2+1 = 14, la mitad. Y 7 píxeles con
padding 1 y stride 2 → (7+2-3)//2+1 = 4: la división entera redondea hacia abajo, por eso la
secuencia del libro es 14 → 7 → 4 → 2 → 1 y no una mitad exacta cada vez.
Regla práctica del libro: kernel 3×3 con padding 1 es lo más común; stride-1 para agregar profundidad sin cambiar tamaño, stride-2 para reducir el mapa de activación paso a paso (14×14 → 7×7 → 4×4 → 2×2 → 1×1).
3. Conv2d en PyTorch y receptive fields
PyTorch nunca convoluciona una sola imagen: trabaja sobre un batch, y espera tensores de rank 4 con un
orden fijo de ejes. La función de bajo nivel es F.conv2d; el libro apila sus cuatro kernels
de bordes y los aplica a un batch de 64 dígitos de una vez:
edge_kernels = torch.stack([left_edge, top_edge, diag1_edge, diag2_edge])
edge_kernels = edge_kernels.unsqueeze(1) # [4, 3, 3] -> [4, 1, 3, 3] = [out, in, ks, ks]
xb, yb = first(dls.valid)
xb.shape # [64, 1, 28, 28] = [batch, canales, alto, ancho]
batch_features = F.conv2d(xb, edge_kernels)
batch_features.shape # [64, 4, 26, 26]: 4 mapas por imagen, 26 por falta de padding
Leé los dos shapes juntos: la entrada es [batch, in_channels, alto, ancho]; el kernel es
[out_channels, in_channels, ks, ks]. Cada uno de los 4 kernels tiene profundidad 1 porque la
imagen tiene 1 canal (escala de grises); produce un mapa de activación por imagen, así que la salida
tiene 4 canales. El unsqueeze(1) es exactamente lo que agrega esa dimensión
in_channels=1. La versión con parámetros aprendibles es nn.Conv2d, que crea
el tensor de pesos con ese mismo shape, más un bias por canal de salida:
conv1 = nn.Conv2d(1, 4, kernel_size=3)
conv1.weight.shape, conv1.bias.shape # [4, 1, 3, 3], [4]
sum(p.numel() for p in conv1.parameters()) # 1*4*3*3 + 4 = 40
La cuenta general es in × out × ks × ks + out: cada canal de salida es un kernel completo de
forma in_channels × ks × ks, más un bias propio. Un Conv2d(3, 64, 3) tiene
3·64·9 + 64 = 1.792 parámetros; un Conv2d(64, 128, 3), 64·128·9 + 128 =
73.856. Lo notable es que ese número no depende del tamaño de la imagen: el mismo kernel se
reutiliza en cada posición — eso es lo que hace que una CNN tenga muchos menos parámetros que una capa
lineal sobre los píxeles aplanados. La función auxiliar del libro:
def conv(ni, nf, ks=3, act=True):
res = nn.Conv2d(ni, nf, stride=2, kernel_size=ks, padding=ks//2)
if act: res = nn.Sequential(res, nn.ReLU())
return res
Regla práctica: al usar stride-2 (reducir la grilla en un factor de 4), conviene duplicar el número de
filtros para no perder capacidad de cómputo neta entre capas. Hacé la cuenta con 4 → 8 canales de 14×14
a 7×7: 4·14·14 = 784 activaciones antes, 8·7·7 = 392 después. Duplicar los
canales deja la capa con la mitad de activaciones, no un cuarto; sin duplicar, cada capa stride-2 tira el
75 % de la información.
El receptive field de una activación es el área de la imagen original que influye en su cálculo. Con dos capas stride-2 consecutivas, una sola celda de la segunda capa ya depende de un área 7×7 de la imagen de entrada — más grande que el kernel 3×3 de cada capa individual, porque cada celda de la primera capa ya "resume" su propia ventana. Cuantas más capas stride-2 haya antes de una activación, mayor su receptive field — por eso las capas más profundas necesitan más canales: cubren áreas cada vez más grandes y deben representar conceptos semánticamente más ricos (ojos, pelaje, ruedas), no solo bordes.
📐 Deducción recursiva del Campo Receptivo (Receptive Field)
El campo receptivo de una neurona en la capa respecto a la imagen de entrada original se calcula recursivamente mediante el salto acumulativo (stride jump) :
Para kernels de () y stride 2 ():
- Capa 1: , .
- Capa 2: , .
- Capa 3: , .
- Capa 4: (cubre sobradamente la imagen de ).
Esto demuestra por qué apilar convoluciones con submuestreo espacial expande el campo receptivo de forma exponencial () manteniendo un crecimiento estrictamente lineal en el número de parámetros.
4. Imágenes a color y batch normalization
Una imagen color es un tensor rank-3 [canales, filas, columnas] — en PyTorch el canal va
primero (CHW), a diferencia de PIL y NumPy, que entregan [filas, columnas, canales]
(HWC). El kernel deja de ser ks×ks y pasa a tener profundidad ch_in×ks×ks: en
cada ventana se multiplica cada canal (R, G, B) por su porción correspondiente del kernel, y los tres
resultados se suman en un solo número por ubicación y por filtro de salida. No hace falta nada especial
para entrenar con color más allá de que la primera capa tenga 3 canales de entrada:
nn.Conv2d(3, nf, 3), con pesos [nf, 3, 3, 3].
Entrenar con learning rates altos sin normalizar produce redes inestables — activaciones cerca de cero,
ciclos de crecimiento exponencial seguidos de colapso. En el notebook, la CNN para los 10 dígitos con
fit_one_cycle(1, 0.06) se queda en una accuracy del orden del azar hasta que se agrega
normalización. Batch normalization (Ioffe & Szegedy, 2015) normaliza las activaciones
de una capa usando la media y desviación estándar del mini-batch, y agrega dos parámetros entrenables
(gamma, beta) para que la red pueda reescalar/desplazar esa salida normalizada
según le convenga. Se agrega a la función conv como nn.BatchNorm2d(nf) después
de la convolución. En entrenamiento usa estadísticas del batch actual; en validación, un promedio móvil
acumulado durante el entrenamiento (running_mean, running_var) — por eso
model.eval() importa: cambia qué estadísticas se usan. Permite usar learning rates mucho más
altos y generaliza mejor, gracias a la aleatoriedad extra que introduce entre mini-batches distintos.
📐 Formulación analítica de Batch Normalization (Ioffe & Szegedy, 2015)
Para un mini-batch , la normalización opera canal por canal calculando la media y varianza muestrales:
Las activaciones se normalizan y escalan mediante los parámetros entrenables (escala) y (desplazamiento):
Durante el paso hacia adelante, se actualizan las estadísticas móviles para inferencia con factor de momento :
El otro ingrediente de estabilidad es 1cycle, la política detrás de
fit_one_cycle (Leslie Smith): el learning rate arranca bajo, sube linealmente hasta el máximo
que le pasás durante el primer tramo del entrenamiento (warmup, que evita que los pesos
aleatorios iniciales salten a una región mala), y después baja suavemente hacia casi cero
(annealing, que deja al modelo asentarse en un mínimo más plano). El momentum hace el recorrido
inverso. learn.recorder.plot_sched() muestra las dos curvas.
🟢 Lo que sigue: ResNet y las skip connections
Batchnorm y 1cycle no alcanzan cuando la red se hace realmente profunda: apilar 30 o 50 capas
conv como las de acá entrena peor que apilar 15, aunque en teoría podría
representar lo mismo. La solución de He et al. (2015) es la skip connection: cada
bloque calcula x + conv2(conv1(x)) en vez de conv2(conv1(x)), de modo que la
capa solo tiene que aprender la diferencia respecto de la identidad, y el gradiente tiene un
camino directo hacia atrás. Eso es una ResNet, la arquitectura que venís usando desde la Lección 1. Por
qué funciona, con mediciones de activaciones y gradientes capa por capa, es el tema de la
Parte 2, Unidad 2.
5. Una CNN completa desde cero vs. transfer learning
simple_cnn = sequential(
conv(1 ,4), #14x14
conv(4 ,8), #7x7
conv(8 ,16), #4x4
conv(16,32), #2x2
conv(32,2, act=False), #1x1
Flatten(),
)
learn = Learner(dls, simple_cnn, loss_func=F.cross_entropy, metrics=accuracy)
learn.summary()
Input 64×1×28×28 · Conv2d 64×4×14×14 (40 params) · ReLU · Conv2d 64×8×7×7 (296) · ReLU · Conv2d 64×16×4×4 (1,168) · ReLU · Conv2d 64×32×2×2 (4,640) · ReLU · Conv2d 64×2×1×1 (578) · Flatten 64×2 — Total params: 6,722
learn.summary() es la herramienta para ver el shape de salida de cada capa, y con ella
podés verificar las dos fórmulas de esta lección a la vez: cada Conv2d reduce alto y ancho según
(n+2-3)//2+1, y sus parámetros son in·out·9 + out (40 = 1·4·9+4;
296 = 4·8·9+8; 1.168 = 8·16·9+16; 4.640 = 16·32·9+32; 578 = 32·2·9+2). La última capa no lleva ReLU
porque su salida son los logits que van directo a cross_entropy; Flatten pasa
de [64, 2, 1, 1] a [64, 2]. Toda la red tiene 6.722 parámetros — menos que una
sola capa lineal de 784 entradas a 10 salidas (7.850). Y con learn.fit_one_cycle(2, 0.01)
clasifica 3 vs. 7 con más del 98 % de accuracy.
Entrenando MNIST completo (los 10 dígitos, no solo 3 vs. 7) desde cero con una CNN algo más ancha
(primer kernel 5×5, canales 8 → 16 → 32 → 64 → 10) más batchnorm y fit_one_cycle(5, 0.1), el
notebook llega a ~98,7 % de accuracy en cinco epochs. Es un resultado muy bueno para una red de unos
pocos miles de parámetros entrenada desde cero — pero sigue por debajo de lo que da una arquitectura
moderna preentrenada, y la brecha se agranda cuanto más difícil es el dataset. La lección implícita de
todo el capítulo: transfer learning con una arquitectura probada (ResNet) sigue siendo superior a una
CNN pequeña entrenada desde cero, incluso aplicando todos los trucos de estabilidad vistos acá (batch
size, 1cycle, batchnorm). Entender la convolución por dentro no es un ejercicio académico separado de la
práctica — es lo que te permite saber por qué vision_learner(dls, resnet34) de la
Lección 1 funciona tan bien de entrada.
6. Errores comunes
La mayoría no tira una excepción: la red entrena, y el resultado es malo por una razón de shapes o de estadísticas.
| Síntoma | Causa probable | Qué hacer |
|---|---|---|
| Después de 5 capas la imagen quedó de 18×18 en vez de 28×28, y el modelo ignora lo que hay en las esquinas | Sin padding, cada Conv2d 3×3 recorta 1 píxel por lado, y los píxeles del borde participan en menos ventanas que los del centro | padding=ks//2 con kernel impar; verificá con la fórmula de §2 o con learn.summary() |
RuntimeError: expected input to have 1 channel, but got 28 channels (o accuracy de azar con imágenes color) | Tensor en orden HWC (como lo entrega PIL/NumPy) pasado a una capa que espera CHW | tensor(img).permute(2, 0, 1); agregá la dimensión de batch con [None]; imprimí x.shape antes de la primera capa |
| Accuracy se estanca aunque agregás capas stride-2 | Cada stride-2 tira el 75 % de las activaciones; si no duplicás los filtros, la capa pierde capacidad neta | Duplicá nf en cada reducción (4 → 8 → 16 → 32), como en simple_cnn |
| Validación mucho peor que entrenamiento, o predicciones distintas para la misma imagen según con qué otras se procese | Batchnorm en modo train() durante inferencia: normaliza con las estadísticas del batch actual (y con batch de 1, la varianza es cero); o entrenaste con batches tan chicos que las running stats quedaron ruidosas | model.eval() antes de predecir (learn.predict ya lo hace); entrená con batch ≥ 32, o usá GroupNorm/LayerNorm si el batch tiene que ser chico |
Loss en nan o accuracy de azar con lr=0.1 | Learning rate alto sin batchnorm: las activaciones crecen exponencialmente capa a capa y colapsan | Agregá nn.BatchNorm2d(nf) en conv, usá 1cycle; sin normalización, bajá el lr un orden de magnitud |
size mismatch en la capa lineal final al cambiar el tamaño de imagen | El shape aplanado depende de la resolución de entrada; una CNN que termina en Flatten + Linear queda atada a un tamaño | Terminá en un mapa 1×1 como simple_cnn, o usá nn.AdaptiveAvgPool2d(1) antes de Flatten |
7. Resumen — cierre de la Parte 1
- Una convolución es multiplicación elemento a elemento + suma de un kernel chico deslizado sobre la imagen — nada más misterioso que eso. Un kernel de bordes marca el borde superior de un trazo en positivo y el inferior en negativo.
- Padding preserva el tamaño espacial entre capas; stride lo reduce deliberadamente, como alternativa a pooling. Salida =
(n + 2·pad − ks)//stride + 1. - PyTorch trabaja con
[batch, canales, alto, ancho]; un kernel es[out, in, ks, ks]y una Conv2d tienein·out·ks² + outparámetros, independientes del tamaño de la imagen. - El receptive field crece con la profundidad — cada capa stride-2 adicional hace que una activación "vea" un área más grande de la imagen original, y por eso las capas profundas necesitan más canales.
- Batch normalization estabiliza el entrenamiento con learning rates altos, normalizando activaciones por mini-batch (y con running stats en evaluación); 1cycle suma warmup y annealing del lr.
- Una CNN desde cero con todos los trucos de estabilidad llega lejos en MNIST, pero no alcanza a un ResNet preentrenado — transfer learning sigue ganando, y las skip connections son el siguiente paso.
Con esto se cierra la Parte 1 de fast.ai: ocho lecciones que fueron, en espejo, del extremo opuesto al recorrido de HF — acá empezaste con un modelo funcionando y terminaste mirando adentro del mecanismo; en HF empezaste por la mecánica (atención, tokenización) y terminaste con modelos de razonamiento entrenados con RL. Antes de la Parte 2 (fundamentos matemáticos avanzados y difusión, desde matmul hasta Stable Diffusion), queda un bonus pendiente: la lección de ética de datos.
8. Ponete a prueba
Respondé antes de abrir cada respuesta. Si fallás una, volvé a la sección indicada.
1. Aplicás top_edge a una ventana con tinta blanca (255) en las tres filas. ¿Qué da, y qué significa?
Cero: -765 + 0 + 765. El kernel solo responde a cambios entre la fila de arriba y la de abajo; el interior uniforme de un trazo no es un borde. (§1)
2. Imagen de 32×32, kernel 5×5, padding 2, stride 2. ¿Shape de salida?
(32 + 4 − 5)//2 + 1 = 16. Con padding ks//2 = 2 y stride 1 sería 32 (preservado); stride 2 lo lleva a la mitad. (§2)
3. ¿Cuántos parámetros tiene nn.Conv2d(16, 32, kernel_size=3), y cambia si la imagen es 28×28 o 224×224?
16·32·9 + 32 = 4.640. No cambia: el kernel se reutiliza en cada posición; el tamaño de la imagen solo afecta el shape de la salida y el cómputo, no los parámetros. (§3)
4. ¿Por qué el libro duplica los canales cada vez que usa stride 2?
Porque stride 2 reduce alto y ancho a la mitad cada uno — un cuarto de las activaciones. Duplicar canales deja la mitad, en vez de un cuarto, así que la capa no pierde capacidad de cómputo de golpe. (§3)
5. ¿Qué usa batchnorm para normalizar durante el entrenamiento, y qué durante la evaluación? ¿Qué pasa si te olvidás model.eval() y predecís una imagen sola?
Entrenamiento: media y desviación del mini-batch actual. Evaluación: los promedios móviles acumulados. Sin eval(), una imagen sola se normaliza contra sí misma (varianza cero, media igual al valor) y la predicción queda sin sentido. (§4)
6. ¿Qué es lo único que cambia una skip connection en un bloque de dos convoluciones?
La salida pasa de conv2(conv1(x)) a x + conv2(conv1(x)): el bloque aprende una diferencia respecto de la identidad y el gradiente tiene un camino directo. Es lo que permite entrenar redes muy profundas. (§4)
9. Para practicar
✏️ Ejercicio 1 — El kernel de bordes sobre un dígito sintético, con F.conv2d
Sin fastai ni descarga: dibujá un "dígito" con dos barras (una horizontal y una vertical) en un tensor 28×28 y aplicale los kernels de bordes con F.conv2d. Solo PyTorch y matplotlib, CPU.
import torch, torch.nn.functional as F, matplotlib.pyplot as plt
img = torch.zeros(28, 28)
img[10:14, 5:23] = 1.0 # barra horizontal
img[5:23, 18:22] = 1.0 # barra vertical
top_edge = torch.tensor([[-1.,-1,-1],[0,0,0],[1,1,1]])
left_edge = top_edge.t().clone()
kernels = torch.stack([top_edge, left_edge]).unsqueeze(1) # [2, 1, 3, 3]
xb = img[None, None] # [1, 1, 28, 28]
for pad in (0, 1):
out = F.conv2d(xb, kernels, padding=pad)
print('padding', pad, '->', tuple(out.shape), '| min', out.min().item(), 'max', out.max().item())
fig, ax = plt.subplots(1, 3, figsize=(9, 3))
for a, t, name in zip(ax, [img, out[0,0], out[0,1]], ['imagen', 'top_edge', 'left_edge']):
a.imshow(t, cmap='gray'); a.set_title(name); a.axis('off')
plt.show()
Pista / qué esperar
Shapes (1, 2, 26, 26) sin padding y (1, 2, 28, 28) con padding 1. Valores entre -3 y 3 (tres píxeles de 1 por fila). En el mapa top_edge, la barra horizontal muestra una línea blanca en su borde superior y una negra en el inferior, y la barra vertical casi no aparece; en left_edge es al revés. Extra: sum(p.numel() for p in torch.nn.Conv2d(1, 4, 3).parameters()) tiene que dar 40.
✏️ Ejercicio 2 — Reproducir learn.summary() con hooks, en PyTorch puro
Armá simple_cnn sin fastai y registrá un forward hook por capa que imprima el shape de salida y el número de parámetros. Comprobá que coincide con la tabla de §5 y con la fórmula de §2.
import torch, torch.nn as nn
def conv(ni, nf, ks=3, act=True):
res = nn.Conv2d(ni, nf, stride=2, kernel_size=ks, padding=ks//2)
return nn.Sequential(res, nn.ReLU()) if act else res
simple_cnn = nn.Sequential(conv(1,4), conv(4,8), conv(8,16), conv(16,32), conv(32,2, act=False), nn.Flatten())
def hook(mod, inp, out):
n = sum(p.numel() for p in mod.parameters(recurse=False))
print(f'{type(mod).__name__:10s} -> {tuple(out.shape)} params={n}')
handles = [m.register_forward_hook(hook) for m in simple_cnn.modules() if not isinstance(m, nn.Sequential)]
with torch.no_grad(): simple_cnn(torch.randn(64, 1, 28, 28))
print('total', sum(p.numel() for p in simple_cnn.parameters()))
for h in handles: h.remove()
Pista / qué esperar
Conv2d → (64, 4, 14, 14) params=40; (64, 8, 7, 7) 296; (64, 16, 4, 4) 1168; (64, 32, 2, 2) 4640; (64, 2, 1, 1) 578; Flatten → (64, 2); total 6722. Después cambiá la entrada a torch.randn(64, 1, 32, 32): la última capa deja de ser 1×1 y Flatten da (64, 8) — el error de la última fila de la tabla de §6.
10. Fuentes y para seguir
- fastbook — 13_convolutions.ipynb — el capítulo completo: kernels a mano, F.conv2d, receptive fields, color, batchnorm y la CNN para los 10 dígitos con sus curvas de entrenamiento.
- course.fast.ai — Lesson 8 — el video, con la hoja de cálculo que muestra la convolución celda por celda.
- PyTorch docs — nn.Conv2d — la referencia oficial de shapes, fórmula de salida y parámetros (
groups,dilation) que esta lección no cubre. - Dumoulin & Visin, "A guide to convolution arithmetic for deep learning" (2016) — todas las combinaciones de padding, stride y kernel con figuras; la referencia para cuando la fórmula de §2 no te alcanza.
- Ioffe & Szegedy, "Batch Normalization" (2015) — el paper original; la sección sobre inferencia explica las running stats de §4.
- He et al., "Deep Residual Learning for Image Recognition" (2015) — ResNet y las skip connections; leelo antes de la Parte 2, Unidad 2.