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
- Pixel similarity es un baseline sin aprendizaje real — útil como punto de comparación, sin mecanismo de mejora.
- 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. - Accuracy no sirve como loss porque su gradiente es casi siempre cero;
mnist_losscon sigmoid sí responde a cambios de confianza. - Un modelo lineal tiene techo bajo; agregar una capa oculta con ReLU entre dos capas lineales ya es una red neuronal real.
- 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.