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 , y demostrar por qué
mnist_losssufre 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 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 ()? Porque para parámetros requeriría evaluar 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.
Cómo se lee. El vector de parámetros en el paso es el vector anterior menos el learning rate escalar multiplicado por el gradiente de la función de pérdida . 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.
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.
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 a la probabilidad predicha tras sigmoid (), la Binary Cross-Entropy (BCE) por muestra es:
Derivemos la pérdida respecto al logit aplicando la regla de la cadena. Primero, la derivada de sigmoid . Segundo, la derivada de la pérdida respecto a :
Multiplicando por la regla de la cadena:
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 . Para un batch de tamaño con matriz de datos , el gradiente respecto al vector de pesos es:
¿Por qué mnist_loss () sufre desvanecimiento de gradiente?
Si (cuando ), . Entonces:
.
Si el modelo está fuertemente equivocado (), el término . ¡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 ( si ) o subdesbordar en , PyTorch implementa la reformulación algebraica analítica:
Al evaluar siempre , el argumento del logaritmo está estrictamente contenido en , 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 y las etiquetas tuvieran forma 1D , las reglas de broadcasting de PyTorch alinean las dimensiones por la derecha: y .
Al restarlos en la loss, PyTorch expande ambas matrices sin avisar a una matriz cuadrada de ( 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 () y weights () son buffers 1D continuos organizados en orden Row-Major (C-contiguous, strides ).
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.
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 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 capas lineales sucesivas con matrices de pesos y biases :
Cómo se lee. El producto de cualquier secuencia de matrices afines colapsa en una única matriz equivalente . Además, por las propiedades del rango matricial: . 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 sobre un conjunto compacto y cualquier tolerancia de error , existe una red de tan solo dos capas con suficientes neuronas ocultas tal que:
¿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 () en una red de 2 capas para aproximarse pueden ser representadas con apenas parámetros en una arquitectura profunda con 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íntoma | Causa probable | Qué 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
- 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 (fuera del grafo:.dataotorch.no_grad()), poner el gradiente en cero, repetir — idéntico en un ejemplo de juguete y en MNIST. - 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. - 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. - 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+Learneres exactamente eso, empaquetado. - 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 ?
Porque la derivada de la pérdida respecto a la probabilidad tiene en su denominador el término , que es exactamente idéntico a la derivada de la función sigmoide . 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 () para modelar funciones oscilatorias o composicionales complejas. Una red profunda logra la misma aproximación con apenas 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
- Practical Deep Learning for Coders — Lesson 3: Neural net foundations — la lección en video (2022), con la hoja de cálculo del Titanic y la explicación de ReLU como "la función más simple que rompe la linealidad".
- fastbook — 04_mnist_basics.ipynb — el Capítulo 4 como notebook: todo el código de esta página, en el orden en que el libro lo construye, con las celdas de verificación intermedias.
- PyTorch — Automatic differentiation package (torch.autograd) — referencia de
requires_grad,backward(),no_grad(); leela cuando quieras entender por qué el gradiente se acumula. - torch.nn.BCEWithLogitsLoss — la documentación oficial explica el truco log-sum-exp y por qué es más estable que sigmoid + BCELoss por separado.
- A visual proof that neural nets can compute any function (Michael Nielsen) — la demostración intuitiva, con gráficos interactivos, del teorema de aproximación universal que §6 menciona.