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

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.

Antes de leerlección 1 §2, qué es deep learning · PyTorch 1 §1, tensores (opcional, ayuda con la notación)

🎯 Al terminar vas a poder

  • Explicar el motor interno de autograd (modo reverso, ordenamiento topológico del DAG y vector-Jacobian products) frente a la imposibilidad de evaluar diferencias finitas en redes con millones de parámetros.
  • Derivar analíticamente por la regla de la cadena por qué la combinación Sigmoide + Binary Cross-Entropy cancela los términos no lineales dejando el gradiente ∇wL=1BX⊤(p−y), y demostrar por qué mnist_loss sufre desvanecimiento de gradiente.
  • Escribir de cero un modelo lineal sobre MNIST (operador GEMM en hardware, broadcasting de tensores y strides en memoria) y escalar a una red neuronal de dos capas con ReLU.
  • Demostrar algebraicamente por qué apilar K capas lineales sin no-linealidad colapsa en un hiperplano recto de rango acotado, y enunciar el Teorema de Aproximación Universal frente a la eficiencia exponencial de la profundidad.

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.

from fastai.vision.all import *
path = untar_data(URLs.MNIST_SAMPLE)          # solo 3s y 7s, ~3 MB
threes = (path/'train'/'3').ls().sorted()
sevens = (path/'train'/'7').ls().sorted()

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

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)

Dos detalles de tensores que después vuelven todo el tiempo. Cada imagen es un tensor de enteros 0–255; el .float()/255 la lleva a valores entre 0 y 1 (y sin el .float(), la división entera te dejaría todo en cero). Y mean((-1,-2)) promedia sobre los dos últimos ejes (alto y ancho) y deja el primero — así la misma función sirve para una imagen o para un batch de miles gracias al broadcasting: PyTorch expande mean3 (28×28) para restarlo de cada imagen de un tensor (N×28×28) sin que tengas que escribir un loop.

Este baseline sin ningún parámetro ajustable ya da ~95% 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 (diferenciación automática en modo reverso). ¿Por qué no usar diferencias finitas (f′(w)≈f(w+h)−f(w)h)? Porque para P parámetros requeriría evaluar P+1 forward passes completos. ¡En un modelo de 8B parámetros harían falta 8.000 millones de forward passes para dar un solo paso de optimizador! El modo reverso construye durante el forward un Grafo Acíclico Dirigido (DAG) de nodos de cómputo donde cada tensor resultante tiene una propiedad grad_fn que apunta a su función generadora (AddBackward0, MulBackward0, MmBackward0). Al invocar .backward(), PyTorch realiza un ordenamiento topológico inverso del grafo y ejecuta productos vector-Jacobiano (VJP: Vector-Jacobian Products), calculando el gradiente exacto de todos los parámetros de la red en una única pasada hacia atrás.

wt+1=wt−η∇wL(wt)

Cómo se lee. El vector de parámetros en el paso t+1 es el vector anterior wt menos el learning rate escalar η multiplicado por el gradiente de la función de pérdida ∇wL. En PyTorch, este vector exacto es depositado por autograd en params.grad.

params = torch.randn(3).requires_grad_()   # init
lr = 1e-5

def apply_step(params):
    preds = f(time, params)                # predict
    loss = mse(preds, speed)               # loss
    loss.backward()                        # gradient: llena params.grad
    params.data -= lr * params.grad.data   # step
    params.grad = None                     # limpiar para la próxima vuelta
    return preds

for i in range(10): apply_step(params)   # repeat (stop: cuando el loss deja de bajar)

Dos líneas de apply_step merecen una explicación aparte. params.data -= lr * params.grad.data actualiza los pesos sin que autograd lo registre: .data es el tensor "pelado", fuera del grafo de operaciones. Si escribieras params -= lr * params.grad a secas, PyTorch se queja (a leaf Variable that requires grad is being used in an in-place operation), porque estarías modificando un tensor que está siendo rastreado. La forma moderna y recomendada de decir lo mismo es un bloque with torch.no_grad(): params -= lr * params.grad — el .data del libro es un atajo más viejo que sigue funcionando. Y params.grad = None hace falta porque .backward() acumula (suma) en .grad en vez de sobrescribir: sin esa línea, el segundo paso usaría el gradiente del primero más el del segundo, y así sucesivamente. params.grad.zero_() es el equivalente in-place que usa el loop de MNIST.

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

Los siete pasos de SGD sobre el código de apply_step Init crea los parámetros una sola vez. Después, en un ciclo: predict calcula preds con f, loss mide el error con mse, gradient llama a loss.backward para llenar params.grad, step resta lr por el gradiente y pone el gradiente en None. Repeat vuelve a predict; stop cuando la loss deja de bajar. una vez en cada vuelta del loop ① init params = randn(3) .requires_grad_() ② predict preds = f(time, params) ③ loss loss = mse(preds, speed) ④ gradient loss.backward() → params.grad ⑤ step params.data -= lr * params.grad params.grad = None ⑥ repeat · for i in range(10) ⑦ stop: cuando la loss deja de bajar (o se acabaron las vueltas)
Los siete pasos y la línea de apply_step que los implementa. Todo lo que cambia entre este ejemplo de juguete, el clasificador de MNIST y el Trainer de Hugging Face es qué hay adentro de cada caja; el ciclo es el mismo.

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.

Accuracy en escalón contra loss suave, como función de un peso A la izquierda, la accuracy como función de un peso es un escalón: plana casi en todo el dominio, con gradiente cero, y un salto solo donde una predicción cruza 0,5. A la derecha, una loss suave baja de forma continua y tiene pendiente en cada punto, así que SGD sabe hacia dónde mover el peso. accuracy(w): escalón 0,9 0,4 gradiente = 0 gradiente = 0 salta solo si una predicción cruza 0,5 w → loss(w): suave pendiente ≠ 0: hacia acá w → el mismo peso w en los dos paneles: solo la loss suave devuelve un gradiente en cada punto
Por qué accuracy no sirve como loss. Casi cualquier cambio chico en un peso deja la accuracy exactamente igual: gradiente cero, sin señal. Una loss suave responde a cada cambio de confianza aunque la predicción final no cambie, y esa pendiente es lo único que SGD sabe seguir.

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

torch.where(cond, a, b) es un if vectorizado: devuelve un tensor que toma a en las posiciones donde cond es verdadera y b en el resto. Acá: si el target es 1 (un 3), el error es 1 - p (qué tan lejos de 1 quedó la probabilidad); si es 0 (un 7), el error es p. Dicho de otra forma: la loss es 1 menos la probabilidad que el modelo le asignó a la clase correcta, promediada sobre el batch.

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.

🟢 De mnist_loss a binary cross-entropy: derivación analítica de gradientes

Si llamás pi a la probabilidad predicha tras sigmoid (pi=σ(zi)=11+e−zi), la Binary Cross-Entropy (BCE) por muestra es:

Li=−[yilogpi+(1−yi)log(1−pi)]

Derivemos la pérdida respecto al logit zi aplicando la regla de la cadena. Primero, la derivada de sigmoid σ′(zi)=pi(1−pi). Segundo, la derivada de la pérdida respecto a pi:

∂Li∂pi=−yipi+1−yi1−pi=−yi(1−pi)+pi(1−yi)pi(1−pi)=pi−yipi(1−pi)

Multiplicando por la regla de la cadena:

∂Li∂zi=∂Li∂pi⋅∂pi∂zi=pi−yipi(1−pi)⋅pi(1−pi)=pi−yi

El denominador no lineal se cancela exactamente con la derivada de la sigmoide. El gradiente respecto al logit es puramente el error de predicción pi−yi∈[−1,1]. Para un batch de tamaño B con matriz de datos X∈ℝB×784, el gradiente respecto al vector de pesos w∈ℝ784×1 es:

∇wL=1BX⊤(p−y)∈ℝ784×1

¿Por qué mnist_loss (1−p) sufre desvanecimiento de gradiente? Si Li=1−pi (cuando yi=1), ∂Li∂pi=−1. Entonces: ∂Li∂zi=−pi(1−pi). Si el modelo está fuertemente equivocado (zi→−∞⟹pi→0), el término pi(1−pi)→0. ¡El gradiente desaparece justo cuando el error es máximo! El modelo queda atrapado sin poder corregir sus predicciones erróneas.

Estabilidad numérica en PyTorch con nn.BCEWithLogitsLoss: Para evitar calcular exponenciales que puedan desbordar en float32 (ez=∞ si z>88.7) o subdesbordar en log(0)=−∞, PyTorch implementa la reformulación algebraica analítica:

BCEWithLogits(z,y)=max(z,0)−z⋅y+log(1+e−|z|)

Al evaluar siempre e−|z|∈[0,1], el argumento del logaritmo está estrictamente contenido en [1,2], garantizando inmunidad absoluta contra desbordamientos numéricos en cualquier hardware.

4. El modelo lineal completo, listo para correr

Esto es lo que va entre la montaña rusa y la red de dos capas, y es el código que el libro construye pieza por pieza en el capítulo 4. Primero los datos: aplanar cada imagen 28×28 a un vector de 784 (.view(-1, 28*28), donde -1 significa "el tamaño que haga falta"), etiquetar los 3 con 1 y los 7 con 0, y armar los DataLoaders.

stacked_sevens = torch.stack([tensor(Image.open(o)) for o in sevens]).float()/255

train_x = torch.cat([stacked_threes, stacked_sevens]).view(-1, 28*28)   # [12396, 784]
train_y = tensor([1]*len(threes) + [0]*len(sevens)).unsqueeze(1)          # [12396, 1]
dset = list(zip(train_x, train_y))

valid_3_tens = torch.stack([tensor(Image.open(o)) for o in (path/'valid'/'3').ls()]).float()/255
valid_7_tens = torch.stack([tensor(Image.open(o)) for o in (path/'valid'/'7').ls()]).float()/255
valid_x = torch.cat([valid_3_tens, valid_7_tens]).view(-1, 28*28)
valid_y = tensor([1]*len(valid_3_tens) + [0]*len(valid_7_tens)).unsqueeze(1)
valid_dset = list(zip(valid_x, valid_y))

dl = DataLoader(dset, batch_size=256)          # DataLoader de fastai: mini-batches de 256
valid_dl = DataLoader(valid_dset, batch_size=256)

El unsqueeze(1) convierte el vector de etiquetas de shape [12396] a [12396, 1]. Esto previene uno de los bugs silenciosos más devastadores en PyTorch: si las predicciones tienen forma [B,1] y las etiquetas tuvieran forma 1D [B], las reglas de broadcasting de PyTorch alinean las dimensiones por la derecha: [B,1] y [1,B]. Al restarlos en la loss, PyTorch expande ambas matrices sin avisar a una matriz cuadrada de [B,B] (256×256=65.536 elementos por batch), comparando cada predicción contra todas las etiquetas del lote. La función .mean() devuelve un número escalar que parece razonable, pero los gradientes resultantes son ruido caótico y el modelo jamás aprende.

Física del operador matricial @ (GEMM en hardware): En memoria física, xb ([B,784]) y weights ([784,1]) son buffers 1D continuos organizados en orden Row-Major (C-contiguous, strides (784,1)). Al ejecutar xb@weights, PyTorch no itera con bucles en Python: despacha un kernel optimizado de BLAS/cuBLAS. En GPU, los Streaming Multiprocessors (SMs) cargan bloques de filas y columnas directamente a la memoria ultrarrápida SRAM compartida y a los registros de los núcleos, ejecutando miles de operaciones de acumulación y multiplicación (FMA) por ciclo de reloj con un throughput órdenes de magnitud superior.

Después el modelo y sus parámetros. init_params crea un tensor aleatorio con requires_grad_() ya activado; weights tiene un valor por píxel, bias es un escalar:

def init_params(size, std=1.0): return (torch.randn(size)*std).requires_grad_()

weights = init_params((28*28, 1))
bias = init_params(1)

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

preds = linear1(train_x)                        # [12396, 1], con pesos aleatorios
((preds > 0.0).float() == train_y).float().mean()   # ~0.5: azar, todavía

Y el loop de entrenamiento, que es apply_step de la montaña rusa repartido en funciones chicas. calc_grad hace predict + loss + backward para un batch; train_epoch lo llama por cada mini-batch y da el step sobre cada parámetro; batch_accuracy es la métrica (para humanos) y validate_epoch la promedia sobre el validation set:

def calc_grad(xb, yb, model):
    preds = model(xb)
    loss = mnist_loss(preds, yb)
    loss.backward()

def train_epoch(model, lr, params):
    for xb,yb in dl:
        calc_grad(xb, yb, model)
        for p in params:
            p.data -= p.grad.data * lr
            p.grad.zero_()

def batch_accuracy(xb, yb):
    preds = xb.sigmoid()
    correct = (preds > 0.5) == yb
    return correct.float().mean()

def validate_epoch(model):
    accs = [batch_accuracy(model(xb), yb) for xb,yb in valid_dl]
    return round(torch.stack(accs).mean().item(), 4)

lr = 1.
params = weights, bias
for i in range(20):
    train_epoch(linear1, lr, params)
    print(validate_epoch(linear1), end=' ')
→ 0.8314 0.9017 0.9227 0.9349 0.9432 … 0.9764

Fijate la escala del learning rate: 1.0 acá, contra 1e-5 en la montaña rusa. No hay un "lr correcto" universal — depende de la magnitud de la loss y de los gradientes de cada problema, y por eso existen herramientas como lr_find que vas a usar más adelante. El mini-batch de 256 es el punto medio entre las dos opciones extremas: todo el dataset de una vez (un gradiente exacto, pero un solo paso por época, lento) o un ítem a la vez (muchos pasos, pero cada gradiente es tan ruidoso que el modelo zigzaguea). El "S" de SGD viene justamente de esa estocasticidad.

El mismo mínimo con tres learning rates Tres veces la misma loss con forma de cuenco y el mismo punto de partida. Con learning rate muy bajo los pasos son diminutos y el descenso es lento. Con learning rate adecuado el parámetro llega al mínimo en pocos pasos. Con learning rate muy alto cada paso cruza al otro lado más lejos que antes y la loss crece: diverge. loss ↑ lr demasiado bajo (lr = 0,05) converge, pero de a poquito mínimo lr adecuado (lr = 0,3) converge en pocos pasos mínimo lr demasiado alto (lr = 1,05) cada paso cae más lejos: diverge mínimo un solo parámetro → · el círculo vacío es el punto de partida, el mismo en los tres
Un solo peso, la misma loss, tres learning rates. El paso es lr × gradiente: muy chico y el descenso es lento; muy grande y cada paso cae del otro lado del mínimo más lejos que el anterior, hasta que la loss explota. Por eso 1e-5 converge en la montaña rusa y 1e-2 manda los parámetros a infinito.

El último paso del libro antes de la red es reemplazar las piezas caseras por las de PyTorch y fastai, una por una, comprobando que dan lo mismo: nn.Linear(28*28, 1) junta weights, bias e init_params en un módulo (y expone .parameters()); una clase BasicOptim de cuatro líneas con step() y zero_grad() reemplaza el loop interno de train_epoch, y después SGD de fastai reemplaza a BasicOptim; y Learner(dls, nn.Linear(28*28,1), opt_func=SGD, loss_func=mnist_loss, metrics=batch_accuracy) con learn.fit(10, lr=1.) reemplaza todo el resto. Es la mejor forma de convencerte de que Learner no hace nada que no acabás de escribir.

5. De un modelo lineal a una red neuronal real

El modelo de la sección anterior es puramente lineal: multiplicar el vector de 784 píxeles 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). En 3 vs. 7 llega a ~97% porque el problema es fácil; pero el techo es estructural: un modelo lineal solo puede separar clases con una frontera de decisión "recta" (un hiperplano en el espacio de 784 dimensiones), y hay muchísimos problemas donde eso no alcanza.

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

w1 = init_params((28*28, 30))   # 784 entradas → 30 neuronas ocultas
b1 = init_params(30)
w2 = init_params((30, 1))        # 30 → 1 salida
b2 = init_params(1)

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)
)
dls = DataLoaders(dl, valid_dl)
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. Llega a ~98% en 3 vs. 7 — y, más importante, ya no tiene el techo del modelo lineal. Contá los parámetros: 784×30 + 30 + 30×1 + 1 = 23.581, contra 785 del lineal.

simple_net: 784 → 30 → 1 con un ReLU en el medio, y qué pasa sin el ReLU Arriba: el vector de 784 píxeles pasa por w1 de 784 por 30 más b1, da 30 valores, el ReLU reemplaza los negativos por cero, w2 de 30 por 1 más b2 los lleva a una sola salida, que va a sigmoid y a la loss. Son 23.581 parámetros. Abajo: sin el ReLU, w1 seguido de w2 es una sola matriz de 784 por 1, un modelo lineal de 785 parámetros. Con ReLU: una red de dos capas x 784 w1 · 784×30 + b1 (30) 30 ReLU 30 w2 · 30×1 + b2 (1) 1 → sigmoid → loss 784×30 + 30 = 23.550 30×1 + 1 = 31 total: 23.581 parámetros Sin ReLU: 784 w1 30 w2 1 = un modelo lineal, otra vez w1@w2 es una sola matriz 784×1: 785 parámetros y una frontera recta
Arriba, simple_net con sus formas y su cuenta de parámetros. Abajo, la misma red sin el ReLU: dos matrices seguidas se multiplican en una sola y el modelo vuelve a ser lineal, por más capas que apiles. La no-linealidad no tiene parámetros; lo único que hace es impedir ese colapso.

6. Por qué hace falta la no-linealidad

Hay una razón algebraica rigurosa por la que apilar capas lineales sin activaciones intermedias es una ilusión matemática. Sean K capas lineales sucesivas con matrices de pesos W1,…,WK y biases b1,…,bK:

h1=XW1+b1,h2=(XW1+b1)W2+b2=X(W1W2)+(b1W2+b2) hK=X(∏k=1KWk)+b*=XW*+b*

Cómo se lee. El producto de cualquier secuencia de matrices afines W1W2…WK colapsa en una única matriz equivalente W*. Además, por las propiedades del rango matricial: rango(W*)≤minkrango(Wk). Por más de que apiles 100 capas lineales, la capacidad representativa es idéntica a la de una sola capa lineal, restringida a un hiperplano recto.

El Teorema de Aproximación Universal (Cybenko 1989, Hornik 1991)

Insertar una función no lineal continua no polinómica σ (como ReLU o Sigmoid) entre cada capa rompe de raíz el colapso lineal. El Teorema de Aproximación Universal establece que para cualquier función continua f∈C(K) sobre un conjunto compacto K⊂ℝn y cualquier tolerancia de error ϵ>0, existe una red de tan solo dos capas con suficientes neuronas ocultas m tal que:

|∑i=1mαiσ(wi⊤x+bi)−f(x)|<ϵ

¿Por qué entonces construimos redes profundas de decenas de capas en vez de una sola capa infinitamente ancha? Por la eficiencia exponencial de parámetros (Telgarsky 2016, Mhaskar et al. 2017): funciones que requieren un número exponencial de neuronas (O(2d)) en una red de 2 capas para aproximarse pueden ser representadas con apenas O(d⋅L) parámetros en una arquitectura profunda con L capas. La profundidad permite componer invarianzas y características jerárquicas (píxeles → bordes → formas → partes → objetos semánticos).

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

🔧 En tu proyecto

Cada bloque de Gemma-4 que cuantizaste a NVFP4 es esta misma estructura, repetida: proyecciones lineales (las matrices que cuantizás) separadas por no-linealidades (la activación del MLP, el softmax de la atención). Que la cuantización afecte sobre todo a las matrices y no a las activaciones tiene que ver con esto: los parámetros aprendidos viven en las capas lineales; la no-linealidad no tiene parámetros, solo rompe la colapsabilidad.

7. Errores comunes

Cuando escribís el loop a mano, no hay framework que te proteja — y cada uno de estos bugs produce un modelo que "corre" sin error pero no aprende, o aprende mal.

SíntomaCausa probableQué hacer
La accuracy mejora en la primera época y después empeora o se vuelve errática, con la misma lr que antes funcionaba.Olvidaste p.grad.zero_() / params.grad = None: backward() suma sobre el gradiente anterior, así que cada paso usa la acumulación de todos los pasos previos — un lr efectivo que crece sin parar.Poné a cero el gradiente después de cada step (o antes de cada backward()). Con un optimizador de PyTorch, es opt.zero_grad().
El loss crece en vez de bajar, o aparece nan a las pocas iteraciones.Learning rate demasiado alto: cada paso salta al otro lado del mínimo, más lejos que antes. Con lr=1e-5 la montaña rusa converge; con lr=1e-2 los parámetros se van a infinito.Bajá la lr un orden de magnitud y probá de nuevo. Más adelante, lr_find. Si la loss diverge desde el primer paso, revisá también que la loss esté bien escalada.
El modelo no pasa del azar (~50%), o el loss arranca enorme.No dividiste por 255: los píxeles van de 0 a 255 y xb@weights da valores de cientos, la sigmoid satura y el gradiente es ~0 en todos lados..float()/255 al cargar. En general, entradas en un rango del orden de 0–1 (o normalizadas) antes de cualquier capa lineal.
RuntimeError: ... expected scalar type Float but found Byte, o una división que da todos ceros.Olvidaste .float(): las imágenes cargan como enteros de 8 bits (uint8); la división entera y la multiplicación por pesos float no funcionan.Convertí a float antes de operar. Lo mismo con las etiquetas si usás BCEWithLogitsLoss: targets en float.
Pusiste batch_accuracy como loss_func y los pesos no se mueven nunca.Accuracy tiene gradiente cero casi en todo su dominio (§3); además la comparación > 0.5 ni siquiera es diferenciable.Accuracy como metrics, nunca como loss. Loss: mnist_loss o nn.BCEWithLogitsLoss.
RuntimeError: mat1 and mat2 shapes cannot be multiplied (256x28 and 784x1), o la loss da una matriz [256, 256].Mezclaste shapes: le pasaste imágenes 28×28 a un modelo que espera 784, o las etiquetas son [bs] y las predicciones [bs, 1] y el broadcasting inventó una matriz..view(-1, 28*28) para las entradas, .unsqueeze(1) para las etiquetas. Imprimí xb.shape, yb.shape, preds.shape en el primer batch, siempre.
RuntimeError: a leaf Variable that requires grad is being used in an in-place operation.Actualizaste los parámetros con params -= ... a secas, dentro del grafo de autograd.with torch.no_grad(): alrededor de la actualización, o params.data -= ... como en el libro.

8. 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 (fuera del grafo: .data o torch.no_grad()), poner el gradiente en cero, 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 (1 menos la probabilidad de la clase correcta) sí responde a cambios de confianza, y binary cross-entropy es la misma idea con un log que castiga la sobreconfianza — en PyTorch, nn.BCEWithLogitsLoss, que combina sigmoid y log por estabilidad numérica.
  4. El modelo lineal completo son ~30 líneas: init_params, linear1, calc_grad, train_epoch, batch_accuracy, validate_epoch; llega a ~97% en 3 vs. 7 con mini-batches de 256 y lr=1.
  5. Un modelo lineal tiene techo estructural (frontera recta); agregar una capa oculta con ReLU entre dos capas lineales ya es una red neuronal real, y nn.Sequential + Learner es exactamente eso, empaquetado.
  6. 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.

9. Ponete a prueba

Respondé antes de abrir cada respuesta. Si fallás una, volvé a la sección indicada.

1. ¿Por qué apply_step necesita la línea params.grad = None?

Porque backward() acumula en .grad en vez de sobrescribir. Sin esa línea, el segundo paso usa la suma de los gradientes del primero y el segundo, y así sucesivamente — el lr efectivo crece sin control. (§2)

2. ¿Qué devuelve torch.where(targets==1, 1-p, p) para un batch con targets [1, 0] y p [0.9, 0.2]?

[0.1, 0.2]: para el primer ítem (target 1) toma 1-0.9; para el segundo (target 0) toma 0.2. En ambos casos es "1 menos la probabilidad asignada a la clase correcta". La loss es el promedio, 0.15. (§3)

3. ¿Qué pasa si usás batch_accuracy como loss? ¿Y si usás mnist_loss como métrica?

Como loss, accuracy no da gradiente (es constante a trozos: solo cambia cuando una predicción cruza 0.5), así que SGD no se mueve. Como métrica, mnist_loss funciona pero es poco interpretable: un valor de 0.08 no te dice cuántos dígitos acertaste. Loss para la máquina, métrica para el humano. (§3)

4. Cargaste las imágenes sin dividir por 255. El código corre sin errores. ¿Qué ves y por qué?

Accuracy que no despega del ~50% o loss que no baja: xb@weights da valores de cientos, la sigmoid satura en 0 o 1, y su derivada en esa zona es ~0 — SGD no recibe señal. Escalar las entradas a 0–1 arregla el problema. (§7)

5. ¿Por qué PyTorch ofrece BCEWithLogitsLoss en vez de que apliques sigmoid y después BCELoss?

Estabilidad numérica: si la sigmoid satura a exactamente 0 o 1 en float32, el log de después da -inf y el gradiente NaN. Combinando las dos operaciones algebraicamente (log-sum-exp), nunca se pasa por ese 0. Por eso la versión "with logits" recibe la salida cruda del modelo. (§3)

6. ¿Cuántos parámetros tiene simple_net con 30 neuronas ocultas, y cuántos tendría una red de tres capas lineales sin ReLU entre ellas en términos de capacidad expresiva?

784×30 + 30 + 30×1 + 1 = 23.581 parámetros. Tres capas lineales sin no-linealidad tienen la capacidad expresiva de una sola capa lineal (785 parámetros efectivos): la composición de funciones lineales es lineal. (§5, §6)

7. En la derivación por la regla de la cadena de Sigmoide + BCE, ¿por qué el gradiente del logit resulta simplemente pi−yi?

Porque la derivada de la pérdida respecto a la probabilidad tiene en su denominador el término p(1−p), que es exactamente idéntico a la derivada de la función sigmoide σ′(z)=p(1−p). Al multiplicarse por la regla de la cadena, ambos términos no lineales se cancelan algebraicamente de forma exacta, dejando una relación puramente lineal y proporcional al error de predicción. (§3)

8. Si una red de 2 capas ya puede aproximar cualquier función continua por el Teorema de Aproximación Universal, ¿por qué se construyen redes profundas?

Por la eficiencia exponencial de parámetros (Telgarsky 2016). Una red superficial de 2 capas requiere un número exponencial de neuronas (O(2d)) para modelar funciones oscilatorias o composicionales complejas. Una red profunda logra la misma aproximación con apenas O(d⋅L) parámetros gracias a la reutilización composicional de características en múltiples capas. (§6)

10. Para practicar

✏️ Ejercicio 1 — Correr el modelo lineal entero en CPU

Juntá el código de §1 y §4 en un script o notebook y corrélo. MNIST_SAMPLE pesa unos pocos MB y 20 épocas del modelo lineal tardan segundos en CPU. Imprimí los shapes del primer batch antes de entrenar.

from fastai.vision.all import *
path = untar_data(URLs.MNIST_SAMPLE)
# ... pegá acá la preparación de datos y las funciones de §4 ...
xb, yb = first(dl)
print(xb.shape, yb.shape, xb.dtype, xb.min().item(), xb.max().item())
# esperado: torch.Size([256, 784]) torch.Size([256, 1]) torch.float32 0.0 1.0

lr = 1.
params = weights, bias
for i in range(20):
    train_epoch(linear1, lr, params)
    print(validate_epoch(linear1), end=' ')
Pista / qué esperar

Accuracy que arranca alrededor de 0.5–0.8 en la primera época y sube hasta ~0.97. Si arranca en 0.5 y no se mueve, revisá la tabla de §7 en este orden: división por 255, zero_(), shapes.

✏️ Ejercicio 2 — Reemplazar mnist_loss por nn.BCEWithLogitsLoss

Con el mismo simple_net de nn.Sequential, entrená dos learners: uno con mnist_loss y otro con BCE. La única trampa: BCE exige targets float, y los tuyos son enteros.

train_y_f = train_y.float(); valid_y_f = valid_y.float()
dl_f = DataLoader(list(zip(train_x, train_y_f)), batch_size=256)
valid_dl_f = DataLoader(list(zip(valid_x, valid_y_f)), batch_size=256)
dls_f = DataLoaders(dl_f, valid_dl_f)

def make_net(): return nn.Sequential(nn.Linear(28*28, 30), nn.ReLU(), nn.Linear(30, 1))

learn_a = Learner(dls_f, make_net(), opt_func=SGD, loss_func=mnist_loss, metrics=batch_accuracy)
learn_a.fit(10, 0.1)
learn_b = Learner(dls_f, make_net(), opt_func=SGD, loss_func=nn.BCEWithLogitsLoss(), metrics=batch_accuracy)
learn_b.fit(10, 0.1)
Pista / qué esperar

Accuracies parecidas (ambas > 95%), pero los valores de loss no son comparables entre sí: BCE arranca cerca de 0.69 (= -log(0.5), el valor con predicciones al azar) y baja; mnist_loss arranca cerca de 0.5. Con BCE, el loss suele bajar más rápido en las primeras épocas porque castiga más los errores confiados.

✏️ Ejercicio 3 — Romperlo a propósito

Sobre el loop a mano del ejercicio 1, hacé tres corridas separadas: (a) lr = 100.; (b) comentá la línea p.grad.zero_(); (c) cargá las imágenes sin /255. Anotá qué hace la accuracy en cada caso, sin mirar la tabla de §7 hasta después.

Pista / qué esperar

(a) La accuracy salta erráticamente o aparece nan en la loss. (b) Mejora la primera época y después se degrada — el gradiente acumulado actúa como un lr cada vez más grande. (c) Se queda clavada cerca de 0.5, o alterna entre "todo 3" y "todo 7": la sigmoid está saturada.

11. Fuentes y para seguir