Phase 04 - Lesson 04
Clasificación de Imágenes
This lesson includes a graded coding exercise that runs in your browser, unlocked with lifetime access.
Un clasificador es una función que va de píxeles a una distribución de probabilidad sobre clases. Todo lo demás es fontanería.
Tipo: Build Lenguajes: Python Prerrequisitos: Fase 2 Lección 09 (Evaluación de Modelos), Fase 3 Lección 10 (Mini Framework), Fase 4 Lección 03 (CNNs) Tiempo: ~75 minutos
Objetivos de Aprendizaje
- Construir un pipeline de extremo a extremo de clasificación de imágenes en CIFAR-10: dataset, augmentation, modelo, bucle de entrenamiento, evaluación
- Explicar el papel de cada componente (dataloader, loss, optimizer, scheduler, augmentation) y predecir cómo se manifiesta en la curva de loss el romper cualquiera de ellos
- Implementar mixup, cutout y label smoothing desde cero y justificar cuándo vale la pena agregar cada uno
- Leer una matriz de confusión y una tabla de precisión/recall por clase para diagnosticar fallos de dataset y de modelo más allá de la exactitud agregada
El Problema
Toda tarea de visión que llega a producción se reduce, en algún nivel, a clasificación de imágenes. La detección clasifica regiones. La segmentación clasifica píxeles. La recuperación ordena por similitud a los centroides de clase. Acertar la clasificación — el bucle del dataset, la política de augmentation, la loss, la evaluación — es la habilidad que se transfiere a todas las demás tareas de la fase.
La mayoría de los bugs de clasificación no están en el modelo. Viven en el pipeline: una normalización rota, un conjunto de entrenamiento sin barajar, augmentation que distorsiona las etiquetas, un split de validación contaminado por datos de entrenamiento, una tasa de aprendizaje que diverge silenciosamente después de la época 30. Una CNN que llegaría al 93% en CIFAR-10 con una configuración correcta normalmente saca 70-75% con una rota, y la curva de loss se ve plausible todo el tiempo.
Esta lección conecta todo el pipeline a mano para que cada parte sea inspeccionable. No vas a usar nada de torchvision.datasets que pudiera esconder un bug.
El Concepto
El pipeline de clasificación
flowchart LR
A["Dataset<br/>(imágenes + etiquetas)"] --> B["Augment<br/>(transformaciones aleatorias)"]
B --> C["Normalizar<br/>(media/desviación)"]
C --> D["DataLoader<br/>(lote + shuffle)"]
D --> E["Modelo<br/>(CNN)"]
E --> F["Logits<br/>(N, C)"]
F --> G["Loss de cross-entropy"]
F --> H["Argmax<br/>en la evaluación"]
G --> I["Backward"]
I --> J["Paso del optimizer"]
J --> K["Paso del scheduler"]
K --> E
style A fill:#dbeafe,stroke:#2563eb
style E fill:#fef3c7,stroke:#d97706
style G fill:#fecaca,stroke:#dc2626
style H fill:#dcfce7,stroke:#16a34a
Cada línea de este bucle es donde puede vivir un bug. La cross-entropy recibe logits crudos, no salidas de softmax, así que cualquier model(x).softmax() antes de la loss calcula silenciosamente el gradiente equivocado. Las augmentations se aplican solo a las entradas, no a las etiquetas — excepto el mixup, que mezcla ambas. optimizer.zero_grad() debe ocurrir una vez por paso; saltárselo acumula gradientes y se ve como una tasa de aprendizaje absurdamente inestable. Cada uno de esos bugs aplana la curva de aprendizaje sin lanzar un error.
Cross-entropy, logits y softmax
Un clasificador produce C números por imagen llamados logits. Aplicar softmax los convierte en una distribución de probabilidad:
softmax(z)_i = exp(z_i) / sum_j exp(z_j)
La cross-entropy mide la log-probabilidad negativa de la clase correcta:
CE(z, y) = -log( softmax(z)_y )
= -z_y + log( sum_j exp(z_j) )
La forma del lado derecho es la numéricamente estable (log-sum-exp). El nn.CrossEntropyLoss de PyTorch fusiona softmax + NLL en una sola operación y recibe logits crudos directamente. Aplicar softmax tú mismo antes es casi siempre un bug — calculas log(softmax(softmax(z))), una cantidad sin sentido.
Por qué funciona la augmentation
Una CNN tiene sesgo inductivo para la traslación (por el compartir de pesos), pero ninguna invarianza integrada a crops, flips, color jitter u oclusión. La única forma de enseñarle esas invarianzas es mostrarle píxeles que las ejerciten. Cada transformación aleatoria durante el entrenamiento es una forma de decir: "estas dos imágenes tienen la misma etiqueta; aprende las features que ignoran la diferencia."
Crop original: "perro mirando a la izquierda"
Flip: "perro mirando a la derecha" <- misma etiqueta, píxeles distintos
Rotate(+15): "perro, leve inclinación"
Color jitter: "perro bajo luz más cálida"
RandomErasing: "perro con un parche faltante"
La regla: la augmentation debe preservar la etiqueta. Cutout y rotación en un dígito pueden convertir "6" en "9"; para ese dataset usas rangos de rotación más pequeños y eliges augmentations que respeten las invarianzas específicas de los dígitos.
Mixup y cutmix
La augmentation común transforma píxeles pero mantiene las etiquetas one-hot. Mixup y cutmix rompen eso al interpolar ambas.
Mixup:
lambda ~ Beta(a, a)
x = lambda * x_i + (1 - lambda) * x_j
y = lambda * y_i + (1 - lambda) * y_j
Cutmix:
pega un rectángulo aleatorio de x_j en x_i
y = mezcla de y_i y y_j ponderada por área
Por qué ayuda: el modelo deja de memorizar objetivos one-hot puntiagudos y aprende a interpolar entre clases. La loss de entrenamiento sube, la exactitud de prueba sube. Es la mejora de robustez más barata para cualquier clasificador.
Label smoothing
Un primo del mixup. En lugar de entrenar contra [0, 0, 1, 0, 0], entrena contra [eps/C, eps/C, 1-eps, eps/C, eps/C] para un eps pequeño como 0.1. Impide que el modelo produzca logits arbitrariamente afilados y mejora la calibración a un costo casi nulo. Integrado en nn.CrossEntropyLoss(label_smoothing=0.1) desde PyTorch 1.10.
Evaluación más allá de la exactitud
La exactitud agregada esconde el desbalance. Un clasificador binario 90-10 que siempre predice la clase mayoritaria saca 90%. Las herramientas que de verdad te dicen lo que está pasando:
- Exactitud por clase — un número por clase; aflora de inmediato las categorías con bajo desempeño.
- Matriz de confusión — cuadrícula C x C con la fila i columna j = conteo de la clase verdadera i predicha como clase j; la diagonal es correcta, las de fuera de la diagonal son donde vive tu modelo.
- Top-1 / Top-5 — si la clase correcta está entre las 1 o 5 mejores predicciones; el Top-5 importa para ImageNet porque clases como "Norwich terrier" vs "Norfolk terrier" son genuinamente ambiguas.
- Calibración (ECE) — ¿una predicción con confianza 0.8 acierta el 80% de las veces? Las redes modernas son sistemáticamente sobreconfiadas; corrige con temperature scaling o label smoothing.
Constrúyelo
Paso 1: Un dataset sintético determinista
CIFAR-10 vive en disco. Para hacer esta lección reproducible y rápida construimos un dataset sintético que se parece a CIFAR — imágenes RGB de 32x32 con estructura específica de clase que el modelo debe aprender. El mismísimo pipeline funciona sin cambios en CIFAR-10 real.
import numpy as np
import torch
from torch.utils.data import Dataset
def synthetic_cifar(num_per_class=1000, num_classes=10, seed=0):
rng = np.random.default_rng(seed)
X = []
Y = []
for c in range(num_classes):
centre = rng.uniform(0, 1, (3,))
freq = 2 + c
for _ in range(num_per_class):
yy, xx = np.meshgrid(np.linspace(0, 1, 32), np.linspace(0, 1, 32), indexing="ij")
r = np.sin(xx * freq) * 0.5 + centre[0]
g = np.cos(yy * freq) * 0.5 + centre[1]
b = (xx + yy) * 0.5 * centre[2]
img = np.stack([r, g, b], axis=-1)
img += rng.normal(0, 0.08, img.shape)
img = np.clip(img, 0, 1)
X.append(img.astype(np.float32))
Y.append(c)
X = np.stack(X)
Y = np.array(Y)
idx = rng.permutation(len(X))
return X[idx], Y[idx]
class ArrayDataset(Dataset):
def __init__(self, X, Y, transform=None):
self.X = X
self.Y = Y
self.transform = transform
def __len__(self):
return len(self.X)
def __getitem__(self, i):
img = self.X[i]
if self.transform is not None:
img = self.transform(img)
img = torch.from_numpy(img).permute(2, 0, 1)
return img, int(self.Y[i])
Cada clase obtiene su propia paleta de colores y patrón de frecuencia, más ruido gaussiano para forzar al modelo a aprender la señal en lugar de memorizar píxeles. Diez clases, mil imágenes cada una, permutadas.
Paso 2: Normalización y augmentation
Las dos transformaciones que todo pipeline de visión tiene.
def standardize(mean, std):
mean = np.array(mean, dtype=np.float32)
std = np.array(std, dtype=np.float32)
def _fn(img):
return (img - mean) / std
return _fn
def random_hflip(p=0.5):
def _fn(img):
if np.random.random() < p:
return img[:, ::-1, :].copy()
return img
return _fn
def random_crop(pad=4):
def _fn(img):
h, w = img.shape[:2]
padded = np.pad(img, ((pad, pad), (pad, pad), (0, 0)), mode="reflect")
y = np.random.randint(0, 2 * pad)
x = np.random.randint(0, 2 * pad)
return padded[y:y + h, x:x + w, :]
return _fn
def compose(*fns):
def _fn(img):
for fn in fns:
img = fn(img)
return img
return _fn
Usa reflect-pad antes del crop, no zero-pad, porque los bordes negros son una señal que el modelo aprendería a ignorar de una forma no útil.
Paso 3: Mixup
Mezcla dos imágenes y dos etiquetas dentro del paso de entrenamiento. Implementado como una transformación de lote para que viva junto al forward pass en lugar de dentro del dataset.
def mixup_batch(x, y, num_classes, alpha=0.2):
if alpha <= 0:
return x, torch.nn.functional.one_hot(y, num_classes).float()
lam = float(np.random.beta(alpha, alpha))
idx = torch.randperm(x.size(0), device=x.device)
x_mixed = lam * x + (1 - lam) * x[idx]
y_onehot = torch.nn.functional.one_hot(y, num_classes).float()
y_mixed = lam * y_onehot + (1 - lam) * y_onehot[idx]
return x_mixed, y_mixed
def soft_cross_entropy(logits, soft_targets):
log_probs = torch.log_softmax(logits, dim=-1)
return -(soft_targets * log_probs).sum(dim=-1).mean()
soft_cross_entropy es la cross-entropy contra una distribución de etiquetas suaves. Se reduce al caso one-hot habitual cuando el objetivo es exactamente one-hot.
Paso 4: El bucle de entrenamiento
La receta completa: una pasada sobre los datos, gradientes una vez por lote, scheduler avanzado una vez por época.
import torch
import torch.nn as nn
from torch.utils.data import DataLoader
from torch.optim import SGD
from torch.optim.lr_scheduler import CosineAnnealingLR
def train_one_epoch(model, loader, optimizer, device, num_classes, use_mixup=True):
model.train()
total, correct, loss_sum = 0, 0, 0.0
for x, y in loader:
x, y = x.to(device), y.to(device)
if use_mixup:
x_m, y_soft = mixup_batch(x, y, num_classes)
logits = model(x_m)
loss = soft_cross_entropy(logits, y_soft)
else:
logits = model(x)
loss = nn.functional.cross_entropy(logits, y, label_smoothing=0.1)
optimizer.zero_grad()
loss.backward()
optimizer.step()
loss_sum += loss.item() * x.size(0)
total += x.size(0)
# La exactitud de entrenamiento contra las etiquetas `y` sin mezclar es solo
# una aproximacion cuando el mixup esta activo (el modelo vio objetivos suaves,
# no y). Tratala como una senal de progreso aproximada; confia en la exactitud
# de val para el desempeno real.
with torch.no_grad():
pred = logits.argmax(dim=-1)
correct += (pred == y).sum().item()
return loss_sum / total, correct / total
@torch.no_grad()
def evaluate(model, loader, device, num_classes):
model.eval()
total, correct = 0, 0
loss_sum = 0.0
cm = torch.zeros(num_classes, num_classes, dtype=torch.long)
for x, y in loader:
x, y = x.to(device), y.to(device)
logits = model(x)
loss = nn.functional.cross_entropy(logits, y)
pred = logits.argmax(dim=-1)
for t, p in zip(y.cpu(), pred.cpu()):
cm[t, p] += 1
loss_sum += loss.item() * x.size(0)
total += x.size(0)
correct += (pred == y).sum().item()
return loss_sum / total, correct / total, cm
Cinco invariantes que verificas cada vez que escribes un bucle de entrenamiento:
model.train()antes del entrenamiento,model.eval()antes de la evaluación — alterna el comportamiento de dropout y batchnorm..zero_grad()antes de.backward()..item()al acumular métricas para que nada mantenga vivo el grafo de computación.@torch.no_grad()durante la evaluación — ahorra memoria y tiempo, evita accidentes sutiles.- Argmax contra logits crudos, no softmax — mismo resultado, una operación menos.
Paso 5: Júntalo todo
Usa el TinyResNet de la lección anterior, entrena por algunas épocas, evalúa.
from main import synthetic_cifar, ArrayDataset
from main import standardize, random_hflip, random_crop, compose
from main import mixup_batch, soft_cross_entropy
from main import train_one_epoch, evaluate
# TinyResNet viene de la leccion anterior (03-cnns-lenet-to-resnet).
# Ajusta la ruta de import a donde guardaste el codigo de la leccion anterior.
from cnns_lenet_to_resnet import TinyResNet # example placeholder
X, Y = synthetic_cifar(num_per_class=500)
split = int(0.9 * len(X))
X_train, Y_train = X[:split], Y[:split]
X_val, Y_val = X[split:], Y[split:]
mean = [0.5, 0.5, 0.5]
std = [0.25, 0.25, 0.25]
train_tf = compose(random_hflip(), random_crop(pad=4), standardize(mean, std))
eval_tf = standardize(mean, std)
train_ds = ArrayDataset(X_train, Y_train, transform=train_tf)
val_ds = ArrayDataset(X_val, Y_val, transform=eval_tf)
train_loader = DataLoader(train_ds, batch_size=128, shuffle=True, num_workers=0)
val_loader = DataLoader(val_ds, batch_size=256, shuffle=False, num_workers=0)
device = "cuda" if torch.cuda.is_available() else "cpu"
model = TinyResNet(num_classes=10).to(device)
optimizer = SGD(model.parameters(), lr=0.1, momentum=0.9, weight_decay=5e-4, nesterov=True)
scheduler = CosineAnnealingLR(optimizer, T_max=10)
for epoch in range(10):
tr_loss, tr_acc = train_one_epoch(model, train_loader, optimizer, device, 10, use_mixup=True)
va_loss, va_acc, _ = evaluate(model, val_loader, device, 10)
scheduler.step()
print(f"epoch {epoch:2d} lr {scheduler.get_last_lr()[0]:.4f} "
f"train {tr_loss:.3f}/{tr_acc:.3f} val {va_loss:.3f}/{va_acc:.3f}")
En el dataset sintético, esto llega a una exactitud de validación casi perfecta en cinco épocas, que es el punto: el pipeline es correcto, el modelo puede aprender lo que es aprendible. Cambia el dataset por CIFAR-10 real y el mismo bucle entrena hasta ~90% sin cambios.
Paso 6: Lee la matriz de confusión
La exactitud por sí sola nunca te dice dónde está fallando el modelo. La matriz de confusión sí.
def print_confusion(cm, labels=None):
c = cm.shape[0]
labels = labels or [str(i) for i in range(c)]
print(f"{'':>6}" + "".join(f"{l:>5}" for l in labels))
for i in range(c):
row = cm[i].tolist()
print(f"{labels[i]:>6}" + "".join(f"{v:>5}" for v in row))
print()
tp = cm.diag().float()
fp = cm.sum(dim=0).float() - tp
fn = cm.sum(dim=1).float() - tp
prec = tp / (tp + fp).clamp_min(1)
rec = tp / (tp + fn).clamp_min(1)
f1 = 2 * prec * rec / (prec + rec).clamp_min(1e-9)
for i in range(c):
print(f"{labels[i]:>6} prec {prec[i]:.3f} rec {rec[i]:.3f} f1 {f1[i]:.3f}")
_, _, cm = evaluate(model, val_loader, device, 10)
print_confusion(cm)
Las filas son las clases verdaderas, las columnas son las predicciones. Un cúmulo de conteos fuera de la diagonal entre las clases 3 y 5 significa que el modelo confunde a las dos y te da un punto de partida para una recolección de datos dirigida o una augmentation específica de clase.
Úsalo
torchvision empaqueta todo lo anterior en componentes idiomáticos. Para CIFAR-10 real, el pipeline completo son cuatro líneas más un bucle de entrenamiento.
from torchvision.datasets import CIFAR10
from torchvision.transforms import Compose, RandomCrop, RandomHorizontalFlip, ToTensor, Normalize
mean = (0.4914, 0.4822, 0.4465)
std = (0.2470, 0.2435, 0.2616)
train_tf = Compose([
RandomCrop(32, padding=4, padding_mode="reflect"),
RandomHorizontalFlip(),
ToTensor(),
Normalize(mean, std),
])
eval_tf = Compose([ToTensor(), Normalize(mean, std)])
train_ds = CIFAR10(root="./data", train=True, download=True, transform=train_tf)
val_ds = CIFAR10(root="./data", train=False, download=True, transform=eval_tf)
Dos cosas para notar: la media/desviación son específicas del dataset — calculadas sobre el conjunto de entrenamiento de CIFAR-10, no sobre ImageNet — y el reflect pad es la política de crop por defecto de la comunidad. Copiar y pegar las estadísticas de ImageNet aquí es una fuga de ~1% de exactitud que nadie detecta hasta que alguien perfila el modelo.
Entrégalo
Esta lección produce:
outputs/prompt-classifier-pipeline-auditor.md— un prompt que audita un script de entrenamiento para los cinco invariantes anteriores y aflora la primera violación.outputs/skill-classification-diagnostics.md— una skill que, dada una matriz de confusión y una lista de nombres de clases, resume los fallos por clase y propone la única corrección de mayor impacto.
Ejercicios
- (Fácil) Entrena el mismo modelo con y sin mixup por cinco épocas en el dataset sintético. Grafica la loss de entrenamiento y de validación para ambos. Explica por qué la loss de entrenamiento con mixup es mayor, aunque la exactitud de validación sea similar o mejor.
- (Medio) Implementa Cutout — pon en cero un cuadrado aleatorio de 8x8 en cada imagen de entrenamiento — y corre una ablación contra ninguna augmentation, hflip+crop, hflip+crop+cutout, hflip+crop+mixup. Reporta la exactitud de validación de cada uno.
- (Difícil) Construye un pipeline para CIFAR-100 (100 clases, mismo tamaño de entrada) y reproduce un entrenamiento de ResNet-34 dentro del 1% de la exactitud publicada. Extras: barre tres tasas de aprendizaje y dos weight decays, registra en un CSV local, produce la tabla final de matriz-de-confusión-top-confusiones.
Términos Clave
| Término | Lo que la gente dice | Lo que realmente significa |
|---|---|---|
| Logits | "Salidas crudas" | El vector pre-softmax de C números por imagen; la cross-entropy espera estos, no valores con softmax aplicado |
| Cross-entropy | "La loss" | Log-probabilidad negativa de la clase correcta; combina log-softmax y NLL en una operación estable |
| DataLoader | "El loteador" | Envuelve un dataset con shuffling, batching y (opcional) carga multi-worker; carga con la culpa de la mitad de los bugs de entrenamiento |
| Augmentation | "Transformaciones aleatorias" | Cualquier transformación a nivel de píxel en el momento del entrenamiento que preserva la etiqueta; enseña invarianzas que la CNN no tiene de forma nativa |
| Mixup / Cutmix | "Mezclar dos imágenes" | Combina tanto las entradas como las etiquetas para que el clasificador aprenda interpolaciones suaves en lugar de fronteras duras |
| Label smoothing | "Objetivos más suaves" | Reemplaza one-hot con (1-eps, eps/(C-1), ...); mejora la calibración y aumenta levemente la exactitud |
| Exactitud Top-k | "Top-5" | La clase correcta está entre las k predicciones de mayor probabilidad; usada en datasets con clases genuinamente ambiguas |
| Matriz de confusión | "Dónde viven los errores" | Tabla C x C donde la entrada (i, j) cuenta imágenes de la clase verdadera i predichas como j; la diagonal está bien, la de fuera de la diagonal te dice qué corregir |
Lectura Adicional
- CS231n: Training Neural Networks — todavía el recorrido más claro por el pipeline de entrenamiento en una sola página
- Bag of Tricks for Image Classification (He et al., 2019) — cada pequeño truco que, en conjunto, agrega 3-4% a la exactitud de ResNet en ImageNet
- mixup: Beyond Empirical Risk Minimization (Zhang et al., 2017) — el artículo original de mixup; tres páginas de teoría más experimentos convincentes
- Why temperature scaling matters (Guo et al., 2017) — el artículo que probó que las redes modernas están mal calibradas y lo corrigió con un solo parámetro escalar