Curso / fast.ai / Lección 3
● piloto de formato

fast.ai · Practical Deep Learning for Coders · Lección 3

Fundamentos: construir SGD y una red neuronal desde cero

La lección más importante del curso en términos matemáticos. Nada de fastai de alto nivel acá — se construye, línea por línea, el mecanismo que hace que cualquier red neuronal aprenda: stochastic gradient descent. Todo lo demás del curso (y del de HF) es una variación sobre esto.

1. El baseline sin aprendizaje: pixel similarity

El ejemplo motivador es distinguir 3s de 7s en MNIST. Antes de cualquier red neuronal, el libro prueba algo mucho más simple: promediar píxel a píxel todas las imágenes de 3 para conseguir un "3 ideal", y lo mismo para el 7.

three_tensors = [tensor(Image.open(o)) for o in threes]
stacked_threes = torch.stack(three_tensors).float()/255
mean3 = stacked_threes.mean(0)   # promedio sobre el eje de las imágenes

def mnist_distance(a,b): return (a-b).abs().mean((-1,-2))
def is_3(x): return mnist_distance(x,mean3) < mnist_distance(x,mean7)

Este baseline sin ningún parámetro ajustable ya da ~95-98% de accuracy — sorprendentemente bueno. Pero el límite es estructural, no de tuning: no hay nada que mejorar. No escala más allá de 2 clases muy distintas visualmente, y no hay ningún mecanismo de aprendizaje real detrás. Sirve como punto de partida exactamente para mostrar la diferencia con lo que sigue: un sistema que sí puede mejorar con datos.

2. SGD en un ejemplo de juguete, antes de tocar MNIST

El proceso completo de aprendizaje se resume en siete pasos: init → predict → loss → gradient → step → repeat → stop. Antes de aplicarlo a MNIST, el libro lo ilustra con una parábola con ruido (la velocidad de una montaña rusa a lo largo del tiempo):

time = torch.arange(0,20).float()
speed = torch.randn(20)*3 + 0.75*(time-9.5)**2 + 1

def f(t, params):
    a,b,c = params
    return a*(t**2) + (b*t) + c

def mse(preds, targets): return ((preds-targets)**2).mean()

La pieza clave es autograd: marcar un tensor con .requires_grad_() le dice a PyTorch que registre cada operación que se le aplica, para poder calcular derivadas automáticamente después con .backward(). Ejemplo mínimo: con xt = tensor(3.).requires_grad_() y yt = xt**2, yt.backward() deja en xt.grad el valor 6 — la derivada de x² en x=3. El learning rate es el escalar que multiplica al gradiente en cada paso: muy bajo converge lento, muy alto diverge o rebota sin asentarse.

params = torch.randn(3).requires_grad_()   # init

def apply_step(params):
    preds = f(time, params)
    loss = mse(preds, speed)
    loss.backward()
    params.data -= lr * params.grad.data
    params.grad = None
    return preds

for i in range(10): apply_step(params)

Esta es literalmente la plantilla que se traslada, sin cambios conceptuales, al clasificador de dígitos.

3. Por qué accuracy no sirve como loss

Un instinto natural sería usar accuracy directamente como función a optimizar. No funciona: su gradiente es cero en casi todo el dominio, porque accuracy solo cambia cuando una predicción cruza el umbral 0.5. Un cambio ínfimo en los pesos casi nunca hace que eso pase, así que (accuracy_nueva - accuracy_vieja)/(cambio en el peso) ≈ 0 — SGD no recibe ninguna señal útil de hacia dónde moverse.

La solución: una función distinta, diferenciable y suave, que sí responda a pequeños cambios de confianza aunque la predicción final no cambie. Primero sigmoid, para acotar cualquier valor real entre 0 y 1 de forma suave:

def sigmoid(x): return 1/(1+torch.exp(-x))

def mnist_loss(predictions, targets):
    predictions = predictions.sigmoid()
    return torch.where(targets==1, 1-predictions, predictions).mean()

mnist_loss da un valor bajo cuando una predicción confiada acierta, y alto cuando una predicción confiada se equivoca — y, a diferencia de accuracy, cambia suavemente con cada ajuste de peso, sin importar cuán lejos esté todavía del umbral de decisión final. Esta es la distinción central entre loss (para que SGD pueda optimizar) y metric (para que un humano entienda qué tan bien va el modelo) — no son intercambiables, y elegir mal la loss deja a SGD ciego aunque la métrica sea perfectamente razonable.

4. De un modelo lineal a una red neuronal real

El primer modelo es puramente lineal: aplanar cada imagen 28×28 a un vector de 784 píxeles (.view(-1, 28*28)) y multiplicar por una matriz de pesos más un bias — la multiplicación matricial (@) es, según el libro, una de las "dos ecuaciones fundamentales de toda red neuronal" (la otra es la función de activación):

def linear1(xb): return xb@weights + bias

Entrenar esto sobre mini-batches (no todo el dataset de una vez —muy lento— ni un ítem a la vez —gradiente demasiado ruidoso—) da un modelo lineal funcional, pero con un techo bajo de accuracy. El problema es estructural: un modelo lineal solo puede separar clases con una frontera de decisión recta.

La solución es agregar una capa oculta con una no-linealidad entre medio — la red mínima de dos capas, escrita a mano:

def simple_net(xb):
    res = xb@w1 + b1
    res = res.max(tensor(0.0))   # esto ES el ReLU completo
    res = res@w2 + b2
    return res

res.max(tensor(0.0)) es toda la definición de ReLU (Rectified Linear Unit): reemplaza los negativos por cero, nada más — no hay ninguna función misteriosa detrás. La versión equivalente con módulos de PyTorch:

simple_net = nn.Sequential(
    nn.Linear(28*28, 30),
    nn.ReLU(),
    nn.Linear(30, 1)
)
learn = Learner(dls, simple_net, opt_func=SGD, loss_func=mnist_loss, metrics=batch_accuracy)
learn.fit(40, 0.1)

Esto ya es una red neuronal real de dos capas: dos transformaciones lineales con una no-linealidad entre medio, entrenada con el mismo loop de SGD del ejemplo de la montaña rusa, solo que ahora ajustando cuatro conjuntos de parámetros (w1, b1, w2, b2) en vez de tres escalares.

5. Por qué hace falta la no-linealidad

Hay una razón matemática precisa, no solo empírica, para por qué no alcanza con apilar capas lineales sin nada entre medio: la composición de funciones lineales es siempre otra función lineal. Una serie de cualquier cantidad de capas lineales seguidas puede reemplazarse por una sola capa lineal con otros parámetros — apilarlas sin no-linealidad no le agrega ninguna capacidad expresiva a la red, por muchas capas que tenga.

Insertar una función no lineal (acá, algo tan simple como un max contra cero) rompe esa colapsabilidad, y eso conecta con el teorema de aproximación universal mencionado en la Lección 1: dos capas lineales más una sola no-linealidad entre medio ya pueden, en principio, aproximar cualquier función computable, dado suficiente ancho (número de neuronas en la capa oculta). Las redes modernas usan muchas más de dos capas no por necesidad teórica, sino por eficiencia práctica: con más capas se necesitan muchos menos parámetros totales para la misma capacidad expresiva, y entrenan más rápido.

📜 El cierre del capítulo: no hay magia

El libro termina esta lección entrenando un ResNet18 completo con vision_learner en una línea, llegando a ~100% de accuracy sobre el mismo problema, para remarcar el punto central: no hay ningún truco adicional en un modelo "real" de producción — son exactamente los mismos siete pasos de SGD, la misma composición lineal + no-linealidad, solo con más capas y trucos de entrenamiento que fastai maneja por vos (los vas a ver en detalle en la Lección 8, sobre convoluciones).

6. Resumen

  1. Pixel similarity es un baseline sin aprendizaje real — útil como punto de comparación, sin mecanismo de mejora.
  2. SGD son siete pasos repetidos: predecir, medir loss, calcular gradiente con .backward(), dar un step, repetir — idéntico en un ejemplo de juguete y en MNIST.
  3. Accuracy no sirve como loss porque su gradiente es casi siempre cero; mnist_loss con sigmoid sí responde a cambios de confianza.
  4. Un modelo lineal tiene techo bajo; agregar una capa oculta con ReLU entre dos capas lineales ya es una red neuronal real.
  5. Apilar capas lineales sin no-linealidad es matemáticamente inútil — se colapsa a una sola capa lineal. La no-linealidad es lo que hace que cada capa adicional aporte algo nuevo.