Curso / fast.ai / Lección 8
fast.ai · 8 de 14

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 Conv2d a 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 (K(−m,−n)):

(I∗K)(i,j)=∑m∑nI(i−m,j−n)K(m,n)

Sin embargo, librerías como PyTorch (torch.nn.Conv2d) y cuDNN implementan en realidad correlación cruzada (cross-correlation):

S(i,j)=(I⋆K)(i,j)=∑m∑nI(i+m,j+n)K(m,n)

Dado que los pesos de K 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:

Hout=⌊Hin+2P−KS⌋+1,Wout=⌊Win+2P−KS⌋+1

Conteo analítico de parámetros: Pconv=Cout×(Cin×Kh×Kw+1).

Una convolución a mano: el kernel de bordes sobre tres ventanas de un trazo El kernel top edge tiene menos uno en la fila de arriba, cero en el medio y uno abajo. Sobre una ventana con fondo negro arriba y tinta blanca abajo da 765; en el interior del trazo, donde las tres filas valen 255, los términos se cancelan y da 0; en el borde inferior da menos 765. Deslizado sobre una imagen de 28 por 28 sin padding produce un mapa de 26 por 26. kernel top_edge · 3×3 -1 -1 -1 0 0 0 1 1 1 −a1−a2−a3 + a7+a8+a9 responde a «abajo más claro que arriba», nada más borde superior del trazo 0 0 0 0 0 0 255 255 255 celda a celda, y sumar +765 interior del trazo 255 255 255 255 255 255 255 255 255 celda a celda, y sumar 0 borde inferior del trazo 255 255 255 255 255 255 0 0 0 celda a celda, y sumar -765 borde de oscuro a claro: alto positivo · interior uniforme: se cancela · borde de claro a oscuro: negativo deslizar la ventana por toda la imagen 28×28 sin padding → 26×26: el borde de 1 píxel no tiene ventana completa el mismo kernel en cada posición: una Conv2d(1, 4, 3) tiene 40 parámetros, sin importar el tamaño de la imagen filas invertidas: detecta lo opuesto · columnas en vez de filas: bordes verticales · una CNN aprende estos kernels por gradiente en vez de escribirlos a mano
Una convolución es multiplicar y sumar, nada más. El kernel de bordes del libro sobre tres ventanas reales de un trazo: marca en positivo el borde superior, en negativo el inferior y en cero el interior uniforme. Deslizarlo sobre la imagen entera produce el mapa de activación.

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

Padding y stride: cuatro casos de la fórmula del tamaño de salida Con 28 píxeles y kernel 3, sin padding la salida es 26; con padding 1 se preserva 28; con padding 1 y stride 2 queda 14; con 7 píxeles, padding 1 y stride 2 queda 4 porque la división entera redondea hacia abajo. Por eso la secuencia de la CNN del libro es 28, 14, 7, 4, 2, 1. salida = (n + 2·pad − ks) // stride + 1 28 · kernel 3 · sin padding · stride 1 (28 + 0 − 3)//1 + 1 = 26 se encoge: las esquinas nunca se tocan 28 · kernel 3 · padding 1 · stride 1 (28 + 2 − 3)//1 + 1 = 28 tamaño preservado: la regla más común 28 · kernel 3 · padding 1 · stride 2 (28 + 2 − 3)//2 + 1 = 14 la mitad: alternativa a pooling 7 · kernel 3 · padding 1 · stride 2 (7 + 2 − 3)//2 + 1 = 4 la división entera redondea hacia abajo la CNN del libro, stride 2 en cada capa: 28 14 7 4 2 1 cada stride-2 tira el 75 % de las activaciones: por eso se duplican los canales (4 → 8 → 16 → 32)
La fórmula de §2 con los cuatro casos que importan. Padding decide si el mapa se encoge; stride decide si se reduce a propósito. La secuencia 28, 14, 7, 4, 2, 1 no es una mitad exacta cada vez porque la división es entera.

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 l respecto a la imagen de entrada original se calcula recursivamente mediante el salto acumulativo (stride jump) Jl:

Jl=Jl−1⋅Slcon J0=1
RFl=RFl−1+(Kl−1)⋅Jl−1con RF0=1

Para kernels de 3×3 (Kl=3) y stride 2 (Sl=2):

  • Capa 1: J1=2, RF1=1+(3−1)⋅1=3.
  • Capa 2: J2=4, RF2=3+(3−1)⋅2=7.
  • Capa 3: J3=8, RF3=7+(3−1)⋅4=15.
  • Capa 4: RF4=15+(3−1)⋅8=31 (cubre sobradamente la imagen de 28×28).

Esto demuestra por qué apilar convoluciones con submuestreo espacial expande el campo receptivo de forma exponencial (O(2L)) 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 B={x1,…,xm}, la normalización opera canal por canal calculando la media y varianza muestrales:

μB=1m∑i=1mxi,σB2=1m∑i=1m(xi−μB)2

Las activaciones se normalizan y escalan mediante los parámetros entrenables γ (escala) y β (desplazamiento):

x^i=xi−μBσB2+ϵ,yi=γx^i+β

Durante el paso hacia adelante, se actualizan las estadísticas móviles para inferencia con factor de momento α:

μrun←(1−α)μrun+αμB,σrun2←(1−α)σrun2+ασB2

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.

simple_cnn capa por capa: el mapa se achica, los canales crecen, el receptive field se agranda La entrada es un batch de 64 imágenes de 1 canal y 28 por 28. Cinco convoluciones stride 2 llevan el mapa a 14, 7, 4, 2 y 1 con 4, 8, 16, 32 y 2 canales; los parámetros de cada capa son 40, 296, 1168, 4640 y 578, en total 6722. Flatten deja 64 por 2 logits. Cada capa stride 2 hace que una activación vea un área más grande de la imagen original. 1×28×28 entrada 4×14×14 conv(1, 4) 40 params 8×7×7 conv(4, 8) 296 params 16×4×4 conv(8, 16) 1.168 params 32×2×2 conv(16, 32) 4.640 params 2×1×1 conv(32, 2) 578 params Flatten → [64, 2] logits, sin ReLU batch de 64 · cada conv: kernel 3, padding 1, stride 2, ReLU · in·out·9 + out parámetros · total 6.722 stride 2 divide alto y ancho por 2 (un cuarto de las activaciones); duplicar canales deja la mitad, no un cuarto receptive field: una celda de la 2ª capa ya depende de 7×7 píxeles de la entrada; cada stride-2 lo agranda learn.summary() muestra exactamente estos shapes y parámetros: dos fórmulas verificadas en una tabla · terminar en 1×1 desata la red del tamaño de imagen
La CNN de §5 vista como una escalera de tensores. Cada capa reduce el mapa y agranda el número de canales para no perder capacidad de golpe; los parámetros no dependen del tamaño de la imagen, y las capas profundas «ven» un área cada vez mayor de la entrada.

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íntomaCausa probableQué 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 esquinasSin padding, cada Conv2d 3×3 recorta 1 píxel por lado, y los píxeles del borde participan en menos ventanas que los del centropadding=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 CHWtensor(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-2Cada stride-2 tira el 75 % de las activaciones; si no duplicás los filtros, la capa pierde capacidad netaDuplicá 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 proceseBatchnorm 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 ruidosasmodel.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.1Learning rate alto sin batchnorm: las activaciones crecen exponencialmente capa a capa y colapsanAgregá 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 imagenEl shape aplanado depende de la resolución de entrada; una CNN que termina en Flatten + Linear queda atada a un tamañoTerminá en un mapa 1×1 como simple_cnn, o usá nn.AdaptiveAvgPool2d(1) antes de Flatten

7. Resumen — cierre de la Parte 1

  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.
  2. Padding preserva el tamaño espacial entre capas; stride lo reduce deliberadamente, como alternativa a pooling. Salida = (n + 2·pad − ks)//stride + 1.
  3. PyTorch trabaja con [batch, canales, alto, ancho]; un kernel es [out, in, ks, ks] y una Conv2d tiene in·out·ks² + out parámetros, independientes del tamaño de la imagen.
  4. 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.
  5. 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.
  6. 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