Saltar a contenido

Laboratorios — Módulo 03: Deep Learning

AI Master Academy · Módulo 03-DEEP-LEARNING · Laboratorios guiados Última actualización: julio de 2026

Bienvenido a los laboratorios del módulo de Deep Learning. Aquí no hay teoría nueva: hay código que funciona, ejecutado paso a paso, con la salida esperada en pantalla y una guía de supervivencia para cuando algo falle (porque fallará, y eso es parte del aprendizaje).

Nota

cada laboratorio es autocontenido: puedes hacerlos en orden o saltar

al que necesites. Todo el código está pensado para ejecutarse en CPU; cuando la GPU acelera de forma significativa lo indicamos y damos la alternativa en Google Colab (menú Entorno de ejecución → Cambiar tipo de entorno → GPU T4).


Índice

# Laboratorio Tema Tiempo
1 Una red neuronal a mano que aprende XOR NumPy puro, backprop, gradient checking 60-90 min
2 El training loop profesional en PyTorch Dataset/DataLoader, AdamW, early stopping 90 min
3 Clasificador de imágenes con transfer learning CNN propia vs ResNet18 preentrenada 90-120 min
4 Construye tu mini-GPT Transformer decoder-only a nivel de carácter 120 min
5 Fine-tuning y embeddings con Hugging Face pipeline, Trainer, sentence-transformers 90-120 min

Requisitos comunes a todos los laboratorios:

  • Módulos 01 (Python/matemáticas) y 02 (Machine Learning clásico) completados.
  • Python 3.10+ con entorno virtual activado (python -m venv .venv).
  • Editor con soporte de Python (VS Code recomendado) o Jupyter/Colab.

Consejo profesional

crea una carpeta labs/ con un subdirectorio por

laboratorio (lab1_xor/, lab2_loop/, …). En una empresa, cada experimento vive en su propio directorio con sus dependencias congeladas (pip freeze > requirements.txt). Acostúmbrate ya.


LAB 1 — Una red neuronal a mano que aprende XOR

Objetivo

Implementar desde cero, solo con NumPy, una red neuronal de 2 capas que aprenda la función XOR: forward pass, backward pass con derivadas analíticas, bucle de entrenamiento, verificación numérica del gradiente (gradient checking) y visualización de la frontera de decisión. Al terminar sabrás exactamente qué hace PyTorch por ti cuando llamas a .backward().

Tiempo estimado: 60-90 minutos.

Requisitos e instalación

# Crear y activar el entorno (si no lo tienes ya)
python -m venv .venv
# Windows:
.venv\Scripts\activate
# Linux/macOS:
# source .venv/bin/activate

# Instalación exacta para este laboratorio
pip install numpy==2.2.* matplotlib==3.10.*

Caso empresarial

XOR parece de juguete, pero es históricamente el problema que

demostró que un perceptrón lineal no basta (Minsky & Papert, 1969) y que las capas ocultas con no linealidad . Cuando en una entrevista te pregunten "¿por qué necesitamos funciones de activación?", XOR es la respuesta perfecta de 30 segundos.

Paso 1 — Los datos: la tabla de verdad de XOR

Crea el archivo lab1_xor/xor.py y empieza por los datos:

# xor.py — Red neuronal desde cero que aprende XOR
import numpy as np                      # única dependencia numérica del lab
import matplotlib.pyplot as plt         # para la frontera de decisión al final

np.random.seed(42)                      # semilla fija: resultados reproducibles

# X: las 4 combinaciones posibles de dos bits, una por fila (forma 4x2)
X = np.array([[0.0, 0.0],
              [0.0, 1.0],
              [1.0, 0.0],
              [1.0, 1.0]])

# y: la salida de XOR para cada fila (1 si los bits son distintos)
y = np.array([[0.0],
              [1.0],
              [1.0],
              [0.0]])                   # forma 4x1: una columna, cuatro ejemplos

print("X:\n", X)
print("y:\n", y.ravel())                # ravel() aplana para imprimir bonito

Salida esperada:

X:
 [[0. 0.]
 [0. 1.]
 [1. 0.]
 [1. 1.]]
y:
 [0. 1. 1. 0.]

Nota

XOR no es linealmente separable: no existe ninguna recta que deje

los 1 a un lado y los 0 al otro. Por eso una regresión logística (módulo 02) fracasa aquí y necesitamos al menos una capa oculta.

Paso 2 — Inicialización de parámetros

# --- Arquitectura: 2 entradas -> 4 neuronas ocultas (tanh) -> 1 salida (sigmoide)
n_in, n_hidden, n_out = 2, 4, 1         # dimensiones de cada capa

# W1: pesos de entrada a capa oculta. Escala pequeña para no saturar tanh.
W1 = np.random.randn(n_in, n_hidden) * 0.5   # forma (2, 4)
b1 = np.zeros((1, n_hidden))                 # sesgos de la capa oculta, forma (1, 4)

# W2: pesos de capa oculta a salida
W2 = np.random.randn(n_hidden, n_out) * 0.5  # forma (4, 1)
b2 = np.zeros((1, n_out))                    # sesgo de salida, forma (1, 1)

Advertencia

si inicializas todos los pesos a cero, todas las neuronas

ocultas calculan lo mismo y reciben el mismo gradiente: la red nunca "rompe la simetría" y no aprende. Inicializar con ruido aleatorio pequeño es obligatorio.

Paso 3 — Funciones de activación y su derivada

def sigmoid(z):
    """Sigmoide: aplasta cualquier real al intervalo (0, 1)."""
    return 1.0 / (1.0 + np.exp(-z))     # fórmula clásica 1/(1+e^-z)

def sigmoid_deriv(a):
    """Derivada de la sigmoide EXPRESADA en función de su salida a=sigmoid(z)."""
    return a * (1.0 - a)                 # truco: σ'(z) = σ(z)·(1-σ(z))

def tanh_deriv(a):
    """Derivada de tanh en función de su salida a=tanh(z)."""
    return 1.0 - a ** 2                  # tanh'(z) = 1 - tanh²(z)

Paso 4 — Forward pass

def forward(X, W1, b1, W2, b2):
    """Propaga X por la red y devuelve la salida y los valores intermedios (cache)."""
    z1 = X @ W1 + b1                     # combinación lineal capa 1, forma (N, 4)
    a1 = np.tanh(z1)                     # activación no lineal, forma (N, 4)
    z2 = a1 @ W2 + b2                    # combinación lineal capa 2, forma (N, 1)
    a2 = sigmoid(z2)                     # probabilidad final en (0,1), forma (N, 1)
    cache = (X, z1, a1, z2, a2)          # guardamos TODO: backward lo necesita
    return a2, cache

Nota

el cache es exactamente lo que PyTorch guarda en su *grafo

computacional* cuando ejecutas operaciones con requires_grad=True. Sin los valores intermedios del forward, el backward es imposible: por eso entrenar consume más memoria que inferir.

Paso 5 — La función de pérdida

def bce_loss(y_pred, y_true):
    """Entropía cruzada binaria, promediada sobre los N ejemplos."""
    eps = 1e-12                                          # evita log(0) = -inf
    y_pred = np.clip(y_pred, eps, 1 - eps)               # recorta a (eps, 1-eps)
    # Fórmula: -(1/N) Σ [ y·log(p) + (1-y)·log(1-p) ]
    return -np.mean(y_true * np.log(y_pred) + (1 - y_true) * np.log(1 - y_pred))

Paso 6 — Backward pass (derivadas analíticas)

Aquí está el corazón del laboratorio. Derivamos la pérdida respecto a cada parámetro aplicando la regla de la cadena capa a capa, de la salida hacia la entrada:

def backward(cache, y_true, W2):
    """Devuelve los gradientes de la pérdida respecto a W1, b1, W2, b2."""
    X, z1, a1, z2, a2 = cache            # desempaquetamos el forward
    N = X.shape[0]                       # número de ejemplos (aquí 4)

    # Gradiente en la salida. Para BCE + sigmoide la derivada respecto a z2
    # se simplifica maravillosamente a (a2 - y): predicción menos verdad.
    dz2 = (a2 - y_true) / N              # forma (N, 1); dividimos por N por el promedio

    dW2 = a1.T @ dz2                     # (4,N)@(N,1) -> (4,1): grad de W2
    db2 = dz2.sum(axis=0, keepdims=True) # suma sobre ejemplos -> (1,1)

    # Propagamos el gradiente hacia la capa oculta a través de W2...
    da1 = dz2 @ W2.T                     # (N,1)@(1,4) -> (N,4)
    # ...y a través de la no linealidad tanh (regla de la cadena):
    dz1 = da1 * tanh_deriv(a1)           # producto elemento a elemento, (N,4)

    dW1 = X.T @ dz1                      # (2,N)@(N,4) -> (2,4): grad de W1
    db1 = dz1.sum(axis=0, keepdims=True) # (1,4)

    return dW1, db1, dW2, db2

Consejo profesional

memoriza el patrón, no las fórmulas: *gradiente local ×

gradiente que llega de arriba*. Cada capa recibe d(salida) y produce d(entrada) y d(parámetros). Es lo mismo que implementa cada Function de autograd en PyTorch.

Paso 7 — Gradient checking: ¿mi backward es correcto?

Antes de entrenar, verifica el gradiente comparándolo con una aproximación numérica por diferencias centradas. Es el test unitario del deep learning artesanal:

def numerical_grad(param, forward_loss_fn, eps=1e-5):
    """Gradiente numérico de la pérdida respecto a 'param', elemento a elemento."""
    grad = np.zeros_like(param)                      # mismo tamaño que el parámetro
    it = np.nditer(param, flags=["multi_index"])     # iteramos cada elemento
    while not it.finished:
        idx = it.multi_index                         # índice (i, j) actual
        old = param[idx]                             # guardamos el valor original
        param[idx] = old + eps                       # perturbamos hacia arriba
        loss_plus = forward_loss_fn()                # pérdida con +eps
        param[idx] = old - eps                       # perturbamos hacia abajo
        loss_minus = forward_loss_fn()               # pérdida con -eps
        param[idx] = old                             # ¡restauramos!
        grad[idx] = (loss_plus - loss_minus) / (2 * eps)  # diferencia centrada
        it.iternext()
    return grad

# --- Comparación analítico vs numérico ---
def full_loss():
    """Pérdida total con los parámetros actuales (clausura sobre W1..b2)."""
    y_hat, _ = forward(X, W1, b1, W2, b2)
    return bce_loss(y_hat, y)

y_hat, cache = forward(X, W1, b1, W2, b2)            # un forward para el cache
dW1, db1, dW2, db2 = backward(cache, y, W2)          # gradientes analíticos

for name, analytic, param in [("W1", dW1, W1), ("b1", db1, b1),
                              ("W2", dW2, W2), ("b2", db2, b2)]:
    num = numerical_grad(param, full_loss)           # gradiente numérico
    # error relativo: ||a - n|| / (||a|| + ||n||); debe ser < 1e-6
    rel = np.linalg.norm(analytic - num) / (np.linalg.norm(analytic) + np.linalg.norm(num) + 1e-12)
    print(f"grad-check {name}: error relativo = {rel:.2e}")

Salida esperada (los exponentes pueden variar ligeramente, pero deben ser ≤ 1e-7):

grad-check W1: error relativo = 2.31e-09
grad-check b1: error relativo = 1.87e-09
grad-check W2: error relativo = 9.40e-10
grad-check b2: error relativo = 4.52e-10

Advertencia

si el error relativo es del orden de 1e-2 o mayor, tu backward

tiene un bug. Los sospechosos habituales: olvidar dividir por N, transponer mal una matriz, o derivar la activación respecto a z cuando la expresaste respecto a a.

Paso 8 — Bucle de entrenamiento

lr = 0.5                                             # tasa de aprendizaje (alta: dataset diminuto)
epochs = 5000                                        # XOR necesita miles de pasadas

for epoch in range(1, epochs + 1):
    y_hat, cache = forward(X, W1, b1, W2, b2)        # 1) forward
    loss = bce_loss(y_hat, y)                        # 2) medir el error
    dW1, db1, dW2, db2 = backward(cache, y, W2)      # 3) backward
    W1 -= lr * dW1                                   # 4) descenso de gradiente...
    b1 -= lr * db1                                   #    ...parámetro a parámetro
    W2 -= lr * dW2
    b2 -= lr * db2
    if epoch % 500 == 0 or epoch == 1:               # log periódico
        print(f"época {epoch:5d} | loss = {loss:.6f}")

# Predicciones finales redondeadas a 0/1
y_final, _ = forward(X, W1, b1, W2, b2)
print("\nPredicciones:", np.round(y_final.ravel(), 3))
print("Clases:      ", (y_final.ravel() > 0.5).astype(int))

Salida esperada — observa la loss bajar época a época, esta es la magia que buscamos:

época     1 | loss = 0.697425
época   500 | loss = 0.679953
época  1000 | loss = 0.542383
época  1500 | loss = 0.161574
época  2000 | loss = 0.048512
época  2500 | loss = 0.025337
época  3000 | loss = 0.016735
época  3500 | loss = 0.012371
época  4000 | loss = 0.009754
época  4500 | loss = 0.008022
época  5000 | loss = 0.006793

Predicciones: [0.007 0.994 0.994 0.007]
Clases:       [0 1 1 0]

La red ha aprendido XOR: predice ~0 para [0,0] y [1,1], y ~1 para los pares mixtos. Fíjate en la meseta inicial (épocas 1-500): la red "duda" hasta que rompe la simetría, y entonces la loss cae en picado.

Paso 9 — Visualizar la frontera de decisión

# Rejilla densa de puntos que cubre el cuadrado [-0.5, 1.5]²
xx, yy = np.meshgrid(np.linspace(-0.5, 1.5, 300),    # 300 valores en x
                     np.linspace(-0.5, 1.5, 300))    # 300 valores en y
grid = np.c_[xx.ravel(), yy.ravel()]                 # 90.000 puntos, forma (90000, 2)

probs, _ = forward(grid, W1, b1, W2, b2)             # probabilidad de "1" en cada punto
probs = probs.reshape(xx.shape)                      # de vuelta a la rejilla 300x300

plt.figure(figsize=(6, 5))
# contourf pinta regiones: azul donde la red predice 0, rojo donde predice 1
plt.contourf(xx, yy, probs, levels=25, cmap="RdBu_r", alpha=0.8)
plt.colorbar(label="P(salida = 1)")
# Los 4 puntos de entrenamiento, coloreados por su etiqueta real
plt.scatter(X[:, 0], X[:, 1], c=y.ravel(), cmap="RdBu_r",
            edgecolors="black", s=200, linewidths=2)
plt.title("Frontera de decisión aprendida para XOR")
plt.xlabel("x1"); plt.ylabel("x2")
plt.tight_layout()
plt.savefig("frontera_xor.png", dpi=120)             # guardamos evidencia del logro
plt.show()

Deberías ver dos "islas" rojas alrededor de (0,1) y (1,0) y zonas azules en las esquinas (0,0) y (1,1): una frontera curva, imposible para un modelo lineal.

Errores comunes

Error (mensaje real) Causa Solución
ValueError: matmul: Input operand 1 has a mismatch in its core dimension 0, with gufunc signature (n?,k),(k,m?)->(n?,m?) (size 4 is different from 2) Multiplicas X @ W2 en lugar de a1 @ W2 o transpones mal en backward Anota la forma de cada matriz en un comentario; verifica que las dimensiones internas coinciden
RuntimeWarning: overflow encountered in exp z demasiado grande en sigmoid (pesos o lr excesivos) Baja lr, inicializa con * 0.5, o usa la versión estable np.where(z>=0, 1/(1+np.exp(-z)), np.exp(z)/(1+np.exp(z)))
RuntimeWarning: divide by zero encountered in log seguido de loss = nan y_pred llegó exactamente a 0 o 1 antes del log Asegúrate de tener el np.clip(y_pred, eps, 1-eps) en bce_loss
La loss se queda clavada en 0.6931 para siempre 0.6931 = ln(2): la red predice 0.5 constante. Pesos a cero o lr demasiado bajo Inicialización aleatoria y lr=0.5; comprueba que restas (-=) y no sumas el gradiente
grad-check da error relativo ~1e-2 Backward incorrecto (falta /N, transposición, derivada de activación mal) Revisa cada línea del Paso 6 contra las formas comentadas
La loss baja y de repente nan lr demasiado alto: el paso "salta" a una zona de saturación numérica Reduce lr a 0.1 y sube epochs a 20000

Cómo depurar

  1. Imprime formas, no valores: print(W1.shape, dW1.shape) — un gradiente siempre tiene la misma forma que su parámetro. Si no, hay bug seguro.
  2. Gradient checking antes de entrenar, nunca después: es tu red de seguridad.
  3. Overfitea un solo ejemplo: entrena solo con X[:1], la loss debe llegar a ~0 en decenas de épocas. Si no puede memorizar UN ejemplo, el backward está roto.
  4. Reduce el problema: prueba primero AND (linealmente separable). Si AND funciona y XOR no, el problema es capacidad/épocas, no el código.
  5. Fija la semilla (np.random.seed(42)) para que los bugs sean reproducibles.

Para ir más lejos

  • Sustituye tanh por ReLU (np.maximum(0, z), derivada (z > 0).astype(float)) y observa si converge más rápido o se atasca (neuronas muertas con solo 4 datos).
  • Reduce a 2 neuronas ocultas (el mínimo teórico para XOR) y mide cuántas semillas de 10 convergen. Lección: la sobreparametrización facilita la optimización.
  • Añade momentum (v = 0.9*v + grad; W -= lr*v) y compara la curva de loss.
  • Generaliza a un dataset de dos lunas (sklearn.datasets.make_moons) con la misma red: verás que tu código ya es un clasificador no lineal genérico.

LAB 2 — El training loop profesional en PyTorch

Objetivo

Construir el bucle de entrenamiento canónico de PyTorch sobre un problema tabular realista (detección de fraude sintético): Dataset y DataLoader propios, MLP con dropout, optimizador AdamW, early stopping, checkpoints del mejor modelo, curvas de entrenamiento/validación y un experimento controlado para ver el overfitting con tus propios ojos.

Tiempo estimado: 90 minutos.

Requisitos e instalación

# CPU es suficiente para este lab (dataset pequeño)
pip install torch==2.7.* --index-url https://download.pytorch.org/whl/cpu
pip install numpy==2.2.* matplotlib==3.10.* scikit-learn==1.6.*

Nota

si ya tienes una instalación de PyTorch con CUDA, sirve igual. El

código detecta el dispositivo automáticamente.

Caso empresarial

el fraude con tarjetas es el "hola mundo" tabular de la

industria financiera: clases desbalanceadas, features numéricas, coste asimétrico de error. Generamos los datos en código para que el lab sea 100% autocontenido, pero la estructura del problema es la real.

Paso 1 — Generar el dataset sintético de fraude

Crea lab2_loop/fraude.py:

# fraude.py — Training loop profesional en PyTorch
import numpy as np
import torch
import torch.nn as nn
from torch.utils.data import Dataset, DataLoader
import matplotlib.pyplot as plt

torch.manual_seed(42)                                # reproducibilidad en PyTorch
np.random.seed(42)                                   # y en NumPy
device = "cuda" if torch.cuda.is_available() else "cpu"  # GPU si existe, si no CPU
print(f"Usando dispositivo: {device}")

def generar_fraude(n=1500, frac_fraude=0.25):
    """Genera transacciones sintéticas. Devuelve X (n, 6) e y (n,)."""
    n_fraude = int(n * frac_fraude)                  # nº de transacciones fraudulentas
    n_normal = n - n_fraude                          # y de legítimas

    # --- Transacciones NORMALES: importes moderados, horas diurnas ---
    importe_n = np.random.lognormal(3.2, 0.8, n_normal)      # importes ~25-80 €
    hora_n = np.clip(np.random.normal(14, 4, n_normal), 0, 23)  # pico a las 14h
    n_ops_dia_n = np.random.poisson(2.5, n_normal)           # ~2-3 compras/día
    dist_km_n = np.random.exponential(8, n_normal)           # cerca de casa
    nuevo_com_n = np.random.binomial(1, 0.15, n_normal)      # 15% comercio nuevo
    extranjero_n = np.random.binomial(1, 0.05, n_normal)     # 5% en el extranjero

    # --- Transacciones FRAUDULENTAS: importes altos, madrugada, lejos ---
    importe_f = np.random.lognormal(4.6, 1.0, n_fraude)      # importes mucho mayores
    hora_f = np.random.choice(24, n_fraude,                  # sobre-representa la madrugada
                              p=np.array([3,3,3,3,2,1,.5,.5,.5,.5,.5,.5,
                                          .5,.5,.5,.5,.5,.5,1,1,1,2,2,2]) / 24.5)
    n_ops_dia_f = np.random.poisson(7, n_fraude)             # ráfagas de operaciones
    dist_km_f = np.random.exponential(300, n_fraude)         # muy lejos de casa
    nuevo_com_f = np.random.binomial(1, 0.7, n_fraude)       # 70% comercio nunca visto
    extranjero_f = np.random.binomial(1, 0.5, n_fraude)      # mitad en el extranjero

    # Apilamos: primero normales (etiqueta 0), luego fraudes (etiqueta 1)
    X = np.vstack([
        np.column_stack([importe_n, hora_n, n_ops_dia_n, dist_km_n, nuevo_com_n, extranjero_n]),
        np.column_stack([importe_f, hora_f, n_ops_dia_f, dist_km_f, nuevo_com_f, extranjero_f]),
    ]).astype(np.float32)
    y = np.concatenate([np.zeros(n_normal), np.ones(n_fraude)]).astype(np.float32)

    perm = np.random.permutation(n)                  # barajamos filas
    return X[perm], y[perm]

X, y = generar_fraude()
print(f"Dataset: {X.shape}, fraudes: {int(y.sum())} ({y.mean():.1%})")

Salida esperada:

Usando dispositivo: cpu
Dataset: (1500, 6), fraudes: 375 (25.0%)

Paso 2 — Split y normalización (sin fugas de datos)

from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler

# 70% train / 15% val / 15% test, estratificado para conservar el 25% de fraude
X_tmp, X_test, y_tmp, y_test = train_test_split(X, y, test_size=0.15,
                                                stratify=y, random_state=42)
X_train, X_val, y_train, y_val = train_test_split(X_tmp, y_tmp, test_size=0.1765,
                                                  stratify=y_tmp, random_state=42)
# 0.1765 de 0.85 ≈ 0.15 del total: así val y test tienen el mismo tamaño

scaler = StandardScaler()                            # media 0, desviación 1
X_train = scaler.fit_transform(X_train)              # fit SOLO con train...
X_val = scaler.transform(X_val)                      # ...y transform en val
X_test = scaler.transform(X_test)                    # ...y test (evita data leakage)

print(f"train={len(X_train)}  val={len(X_val)}  test={len(X_test)}")

Salida esperada:

train=1050  val=225  test=225

Advertencia

hacer fit del scaler con TODOS los datos (incluida validación)

es la fuga de datos más común del mundo real. La media y desviación de val/test son "el futuro": el modelo no debe verlas nunca durante el ajuste.

Paso 3 — Dataset y DataLoader

class FraudeDataset(Dataset):
    """Envuelve arrays NumPy y los sirve como tensores de PyTorch."""
    def __init__(self, X, y):
        self.X = torch.from_numpy(X)                 # tensores float32 (ya lo eran)
        self.y = torch.from_numpy(y).unsqueeze(1)    # (N,) -> (N,1) para BCE

    def __len__(self):
        return len(self.X)                           # nº de ejemplos

    def __getitem__(self, idx):
        return self.X[idx], self.y[idx]              # un par (features, etiqueta)

train_ds = FraudeDataset(X_train, y_train)
val_ds = FraudeDataset(X_val, y_val)
test_ds = FraudeDataset(X_test, y_test)

# shuffle=True SOLO en train: en val/test el orden no debe importar
train_dl = DataLoader(train_ds, batch_size=64, shuffle=True)
val_dl = DataLoader(val_ds, batch_size=256)          # batches grandes: solo inferencia
test_dl = DataLoader(test_ds, batch_size=256)

xb, yb = next(iter(train_dl))                        # inspecciona un batch
print(f"Batch de entrenamiento: X={tuple(xb.shape)}, y={tuple(yb.shape)}")

Salida esperada:

Batch de entrenamiento: X=(64, 6), y=(64, 1)

Paso 4 — El modelo: MLP con dropout

class FraudeMLP(nn.Module):
    """MLP 6 -> 32 -> 16 -> 1 con ReLU y dropout como regularización."""
    def __init__(self, p_drop=0.3):
        super().__init__()                           # inicializa nn.Module
        self.net = nn.Sequential(
            nn.Linear(6, 32),                        # capa 1: 6 features -> 32
            nn.ReLU(),                               # no linealidad
            nn.Dropout(p_drop),                      # apaga 30% de neuronas al entrenar
            nn.Linear(32, 16),                       # capa 2
            nn.ReLU(),
            nn.Dropout(p_drop),
            nn.Linear(16, 1),                        # salida: 1 logit (SIN sigmoide)
        )

    def forward(self, x):
        return self.net(x)                           # devuelve logits crudos

model = FraudeMLP().to(device)                       # mueve parámetros al dispositivo
print(model)
n_params = sum(p.numel() for p in model.parameters())
print(f"Parámetros entrenables: {n_params}")

Salida esperada:

FraudeMLP(
  (net): Sequential(
    (0): Linear(in_features=6, out_features=32, bias=True)
    (1): ReLU()
    (2): Dropout(p=0.3, inplace=False)
    (3): Linear(in_features=32, out_features=16, bias=True)
    (4): ReLU()
    (5): Dropout(p=0.3, inplace=False)
    (6): Linear(in_features=16, out_features=1, bias=True)
  )
)
Parámetros entrenables: 769

Nota

la salida son logits (números reales sin acotar), no probabilidades.

Usaremos BCEWithLogitsLoss, que fusiona sigmoide + BCE de forma numéricamente estable. Poner una nn.Sigmoid() al final Y usar BCEWithLogitsLoss es un bug clásico: aplicarías la sigmoide dos veces.

Paso 5 — Funciones de una época (train y eval)

criterion = nn.BCEWithLogitsLoss()                   # sigmoide + BCE en una op estable
optimizer = torch.optim.AdamW(model.parameters(),
                              lr=1e-3,               # tasa de aprendizaje estándar
                              weight_decay=1e-2)     # regularización L2 desacoplada

def train_epoch(model, dl):
    """Una pasada completa de entrenamiento. Devuelve loss media y accuracy."""
    model.train()                                    # ACTIVA dropout
    total_loss, correct, n = 0.0, 0, 0
    for xb, yb in dl:                                # itera batch a batch
        xb, yb = xb.to(device), yb.to(device)        # mueve el batch al dispositivo
        optimizer.zero_grad()                        # 1) limpia gradientes previos
        logits = model(xb)                           # 2) forward
        loss = criterion(logits, yb)                 # 3) pérdida del batch
        loss.backward()                              # 4) backward (autograd)
        optimizer.step()                             # 5) actualiza parámetros
        total_loss += loss.item() * len(xb)          # acumula ponderando por batch
        correct += ((logits > 0) == yb.bool()).sum().item()  # logit>0 ⟺ prob>0.5
        n += len(xb)
    return total_loss / n, correct / n

@torch.no_grad()                                     # sin grafo: más rápido, menos RAM
def eval_epoch(model, dl):
    """Evaluación sin gradientes. Devuelve loss media y accuracy."""
    model.eval()                                     # DESACTIVA dropout
    total_loss, correct, n = 0.0, 0, 0
    for xb, yb in dl:
        xb, yb = xb.to(device), yb.to(device)
        logits = model(xb)
        total_loss += criterion(logits, yb).item() * len(xb)
        correct += ((logits > 0) == yb.bool()).sum().item()
        n += len(xb)
    return total_loss / n, correct / n

Advertencia

olvidar model.train() / model.eval() es el bug silencioso #1

de PyTorch: el dropout seguiría activo en validación y tus métricas de val serían peores y ruidosas sin ningún error en pantalla.

Paso 6 — Bucle con early stopping y checkpoints

EPOCHS = 100                                         # tope máximo
PATIENCE = 10                                        # épocas sin mejora antes de parar
best_val_loss = float("inf")                         # mejor loss de val vista
epochs_sin_mejora = 0                                # contador de paciencia
historia = {"train_loss": [], "val_loss": [], "train_acc": [], "val_acc": []}

for epoch in range(1, EPOCHS + 1):
    tr_loss, tr_acc = train_epoch(model, train_dl)   # entrena una época
    va_loss, va_acc = eval_epoch(model, val_dl)      # evalúa en validación
    historia["train_loss"].append(tr_loss)           # guardamos para las curvas
    historia["val_loss"].append(va_loss)
    historia["train_acc"].append(tr_acc)
    historia["val_acc"].append(va_acc)

    marca = ""
    if va_loss < best_val_loss:                      # ¿nuevo mejor modelo?
        best_val_loss = va_loss
        epochs_sin_mejora = 0
        torch.save({"model_state": model.state_dict(),   # checkpoint completo:
                    "epoch": epoch,                       # pesos + metadatos
                    "val_loss": va_loss},
                   "mejor_modelo.pt")
        marca = "  <- checkpoint guardado"
    else:
        epochs_sin_mejora += 1

    if epoch % 5 == 0 or marca:
        print(f"época {epoch:3d} | train {tr_loss:.4f}/{tr_acc:.3f} "
              f"| val {va_loss:.4f}/{va_acc:.3f}{marca}")

    if epochs_sin_mejora >= PATIENCE:                # early stopping
        print(f"\nEarly stopping en época {epoch} "
              f"(sin mejora en val desde hace {PATIENCE} épocas)")
        break

# Recuperamos el MEJOR modelo, no el último
ckpt = torch.load("mejor_modelo.pt", weights_only=True)
model.load_state_dict(ckpt["model_state"])
te_loss, te_acc = eval_epoch(model, test_dl)
print(f"\nMejor época: {ckpt['epoch']} | TEST: loss={te_loss:.4f}  acc={te_acc:.3f}")

Salida esperada (los números exactos varían un poco por plataforma, la forma no):

época   1 | train 0.6017/0.741 | val 0.4863/0.756  <- checkpoint guardado
época   2 | train 0.4477/0.800 | val 0.3672/0.867  <- checkpoint guardado
época   3 | train 0.3633/0.851 | val 0.2953/0.907  <- checkpoint guardado
época   5 | train 0.2645/0.898 | val 0.2183/0.933  <- checkpoint guardado
época  10 | train 0.1876/0.925 | val 0.1562/0.951  <- checkpoint guardado
época  15 | train 0.1602/0.937 | val 0.1354/0.956  <- checkpoint guardado
época  20 | train 0.1477/0.941 | val 0.1260/0.960  <- checkpoint guardado
época  27 | train 0.1370/0.946 | val 0.1195/0.964  <- checkpoint guardado
época  35 | train 0.1289/0.949 | val 0.1173/0.964  <- checkpoint guardado

Early stopping en época 45 (sin mejora en val desde hace 10 épocas)

Mejor época: 35 | TEST: loss=0.1248  acc=0.960

Paso 7 — Curvas de entrenamiento

def plot_curvas(historia, titulo, fichero):
    """Dibuja loss y accuracy de train/val lado a lado."""
    fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(11, 4))
    ax1.plot(historia["train_loss"], label="train")   # loss de entrenamiento
    ax1.plot(historia["val_loss"], label="val")       # loss de validación
    ax1.set_xlabel("época"); ax1.set_ylabel("BCE loss")
    ax1.set_title(f"Pérdida — {titulo}"); ax1.legend(); ax1.grid(alpha=0.3)
    ax2.plot(historia["train_acc"], label="train")
    ax2.plot(historia["val_acc"], label="val")
    ax2.set_xlabel("época"); ax2.set_ylabel("accuracy")
    ax2.set_title(f"Accuracy — {titulo}"); ax2.legend(); ax2.grid(alpha=0.3)
    plt.tight_layout(); plt.savefig(fichero, dpi=120); plt.show()

plot_curvas(historia, "modelo regularizado", "curvas_regularizado.png")

Deberías ver ambas curvas de loss bajando juntas y estabilizándose, con un hueco pequeño entre train y val. Eso es un modelo sano.

Paso 8 — Experimento guiado: provocar overfitting

Ahora rompamos el modelo a propósito para reconocer el patrón enfermo:

# Modelo GRANDE, SIN dropout, SIN weight decay, y con pocos datos de train
model_of = FraudeMLP(p_drop=0.0).to(device)          # dropout desactivado
# red más ancha para darle capacidad de sobra de memorizar
model_of.net = nn.Sequential(
    nn.Linear(6, 256), nn.ReLU(),                    # 256 neuronas: sobredimensionado
    nn.Linear(256, 256), nn.ReLU(),
    nn.Linear(256, 1),
).to(device)
optimizer = torch.optim.AdamW(model_of.parameters(), lr=1e-3,
                              weight_decay=0.0)      # sin regularización L2

# Solo 150 ejemplos de train: memorizar es fácil, generalizar no
mini_train_dl = DataLoader(FraudeDataset(X_train[:150], y_train[:150]),
                           batch_size=32, shuffle=True)

hist_of = {"train_loss": [], "val_loss": [], "train_acc": [], "val_acc": []}
for epoch in range(1, 201):                          # 200 épocas SIN early stopping
    tr_loss, tr_acc = train_epoch(model_of, mini_train_dl)
    va_loss, va_acc = eval_epoch(model_of, val_dl)
    for k, v in zip(hist_of, (tr_loss, va_loss, tr_acc, va_acc)):
        hist_of[k].append(v)
    if epoch % 40 == 0:
        print(f"época {epoch:3d} | train {tr_loss:.4f}/{tr_acc:.3f} "
              f"| val {va_loss:.4f}/{va_acc:.3f}")

plot_curvas(hist_of, "overfitting provocado", "curvas_overfitting.png")

Salida esperada — mira cómo train baja hacia cero mientras val empeora:

época  40 | train 0.0912/0.973 | val 0.1731/0.938
época  80 | train 0.0311/1.000 | val 0.2245/0.929
época 120 | train 0.0102/1.000 | val 0.2874/0.924
época 160 | train 0.0041/1.000 | val 0.3352/0.920
época 200 | train 0.0019/1.000 | val 0.3710/0.916

En la gráfica verás las tijeras del overfitting: la loss de train cae en picado hacia 0 (¡accuracy 100%: memorización!) mientras la de val toca fondo pronto y luego sube época tras época. El early stopping del Paso 6 corta exactamente en ese fondo del valle.

Consejo profesional

en una entrevista, saber dibujar estas dos curvas y

señalar dónde pararías el entrenamiento vale más que recitar la definición de overfitting.

Errores comunes

Error (mensaje real) Causa Solución
RuntimeError: Expected all tensors to be on the same device, but found at least two devices, cuda:0 and cpu! El modelo está en GPU pero el batch no (o viceversa) Añade xb, yb = xb.to(device), yb.to(device) dentro del bucle
ValueError: Target size (torch.Size([64])) must be the same as input size (torch.Size([64, 1])) y tiene forma (N,) y los logits (N,1) El .unsqueeze(1) del Dataset lo arregla; no lo quites
RuntimeError: result type Float can't be cast to the desired output type Long Las etiquetas son enteros y BCEWithLogitsLoss exige float y.astype(np.float32) al generar los datos
La val loss es MEJOR que la train loss al principio No es un bug: el dropout penaliza train pero está apagado en eval Nada que arreglar; desaparece al converger
La loss no baja de ~0.56 nunca Olvidaste optimizer.zero_grad(): los gradientes se acumulan entre batches Ponlo como primera línea del bucle de batch
FileNotFoundError: [Errno 2] No such file or directory: 'mejor_modelo.pt' Ejecutas la carga antes de que se guarde ningún checkpoint Verifica que al menos una época mejoró best_val_loss (época 1 siempre mejora)
UserWarning: You are using torch.load with weights_only=False... Versión de PyTorch antigua con default inseguro Usa torch.load(..., weights_only=True) como en el código

Cómo depurar

  1. El test del batch único: antes de entrenar de verdad, ejecuta xb, yb = next(iter(train_dl)) y entrena 200 pasos SOLO con ese batch. La loss debe irse a ~0. Si no, el problema está en modelo/loss, no en los datos.
  2. Comprueba el balance de clases por batch: print(yb.mean()) debe rondar 0.25.
  3. Vigila la norma del gradiente: si sospechas explosión, imprime torch.nn.utils.clip_grad_norm_(model.parameters(), 1e9) (devuelve la norma total).
  4. Compara con un baseline tonto: predecir "siempre 0" da accuracy 0.75 aquí. Si tu red no supera claramente eso, algo estructural falla.
  5. Usa torchinfo.summary(model, (64, 6)) (pip install torchinfo) para auditar formas y número de parámetros capa a capa.

Para ir más lejos

  • Sustituye accuracy por recall y precision del fraude (sklearn.metrics): con clases desbalanceadas, accuracy engaña. Prueba pos_weight en BCEWithLogitsLoss para penalizar más los fraudes perdidos.
  • Añade un scheduler: torch.optim.lr_scheduler.ReduceLROnPlateau sobre la val loss, y observa el efecto en las curvas.
  • Registra los experimentos con TensorBoard (torch.utils.tensorboard) o mlflow: es la práctica estándar en equipos profesionales.
  • Repite el experimento de overfitting con p_drop=0.5 y weight_decay=0.05: ¿cuánto tarda ahora en abrirse la tijera?

LAB 3 — Clasificador de imágenes con transfer learning

Objetivo

Entrenar dos clasificadores sobre FashionMNIST: primero una CNN pequeña propia y después una ResNet18 preentrenada en ImageNet usada como extractor de características (feature extraction). Compararás accuracy y tiempo de entrenamiento, dibujarás la matriz de confusión e inspeccionarás las imágenes mal clasificadas.

Tiempo estimado: 90-120 minutos (en CPU; ~30 min con GPU).

Requisitos e instalación

pip install torch==2.7.* torchvision==0.22.* --index-url https://download.pytorch.org/whl/cpu
pip install matplotlib==3.10.* scikit-learn==1.6.*

Advertencia (descargas)

la primera ejecución descarga FashionMNIST (~30 MB)

y los pesos de ResNet18 (~45 MB). Necesitas conexión a internet. En redes corporativas con proxy, exporta HTTPS_PROXY antes de ejecutar.

Nota (Colab)

si tu CPU es lenta, abre colab.research.google.com,

crea un notebook, activa GPU (Entorno de ejecución → Cambiar tipo de entorno → GPU T4) y pega el código tal cual: torchvision ya viene instalado y device será cuda.

Caso empresarial

el 90% de los proyectos de visión en empresas medianas

NO entrenan desde cero: parten de un modelo preentrenado y lo adaptan. Este lab es, literalmente, el flujo de trabajo que usarás en producción.

Paso 1 — Datos y transformaciones

Crea lab3_transfer/imagenes.py:

# imagenes.py — CNN propia vs ResNet18 preentrenada en FashionMNIST
import time
import numpy as np
import torch
import torch.nn as nn
from torch.utils.data import DataLoader, Subset
import torchvision
from torchvision import transforms
import matplotlib.pyplot as plt

torch.manual_seed(42)
device = "cuda" if torch.cuda.is_available() else "cpu"
print(f"Dispositivo: {device}")

# Nombres de las 10 clases de FashionMNIST, en español
CLASES = ["Camiseta", "Pantalón", "Jersey", "Vestido", "Abrigo",
          "Sandalia", "Camisa", "Zapatilla", "Bolso", "Botín"]

# --- Transformación para la CNN propia: tensor 1x28x28 normalizado ---
tf_cnn = transforms.Compose([
    transforms.ToTensor(),                            # PIL -> tensor [0,1], forma (1,28,28)
    transforms.Normalize((0.2860,), (0.3530,)),       # media/std de FashionMNIST
])

# --- Transformación para ResNet18: espera 3 canales y 224x224 como ImageNet ---
tf_resnet = transforms.Compose([
    transforms.Grayscale(num_output_channels=3),      # replica el canal gris x3
    transforms.Resize(224),                           # 28x28 -> 224x224
    transforms.ToTensor(),
    transforms.Normalize([0.485, 0.456, 0.406],       # estadísticas de ImageNet
                         [0.229, 0.224, 0.225]),      # (las de los pesos preentrenados)
])

def cargar(transform, n_train=6000, n_test=1500):
    """Descarga FashionMNIST y devuelve subsets pequeños para entrenar rápido."""
    train_full = torchvision.datasets.FashionMNIST(
        root="./data", train=True, download=True, transform=transform)
    test_full = torchvision.datasets.FashionMNIST(
        root="./data", train=False, download=True, transform=transform)
    # Subconjuntos: suficientes para aprender, rápidos en CPU
    train = Subset(train_full, range(n_train))        # primeras 6000 imágenes
    test = Subset(test_full, range(n_test))           # primeras 1500 de test
    return train, test

Nota

usamos subconjuntos (6000/1500) para que el lab termine en un tiempo

razonable en CPU. Con GPU puedes usar el dataset completo (60000/10000) cambiando dos números.

Paso 2 — Funciones de entrenamiento reutilizables

criterion = nn.CrossEntropyLoss()                     # multiclase: softmax + NLL

def entrenar(model, train_dl, test_dl, epochs, lr, solo_cabeza=False):
    """Entrena y devuelve (accuracy_test, segundos, historial)."""
    # Si es feature extraction, optimizamos SOLO los parámetros con grad activo
    params = [p for p in model.parameters() if p.requires_grad]
    optimizer = torch.optim.AdamW(params, lr=lr)
    print(f"  parámetros entrenables: {sum(p.numel() for p in params):,}")

    t0 = time.time()                                  # cronómetro
    for epoch in range(1, epochs + 1):
        model.train()
        total, correct, loss_sum = 0, 0, 0.0
        for xb, yb in train_dl:
            xb, yb = xb.to(device), yb.to(device)
            optimizer.zero_grad()
            logits = model(xb)                        # (B, 10) logits
            loss = criterion(logits, yb)              # CrossEntropy con clases int
            loss.backward()
            optimizer.step()
            loss_sum += loss.item() * len(xb)
            correct += (logits.argmax(1) == yb).sum().item()  # clase más probable
            total += len(xb)
        acc_test = evaluar(model, test_dl)            # accuracy en test cada época
        print(f"  época {epoch} | train loss {loss_sum/total:.4f} "
              f"acc {correct/total:.3f} | test acc {acc_test:.3f}")
    segundos = time.time() - t0
    return acc_test, segundos

@torch.no_grad()
def evaluar(model, dl):
    """Accuracy simple sobre un DataLoader."""
    model.eval()
    correct, total = 0, 0
    for xb, yb in dl:
        xb, yb = xb.to(device), yb.to(device)
        correct += (model(xb).argmax(1) == yb).sum().item()
        total += len(xb)
    return correct / total

Paso 3 — Modelo A: CNN pequeña propia

class MiniCNN(nn.Module):
    """CNN clásica: [conv-relu-pool] x2 + clasificador denso."""
    def __init__(self):
        super().__init__()
        self.features = nn.Sequential(
            nn.Conv2d(1, 16, kernel_size=3, padding=1),  # 1x28x28 -> 16x28x28
            nn.ReLU(),
            nn.MaxPool2d(2),                             # -> 16x14x14 (reduce a la mitad)
            nn.Conv2d(16, 32, kernel_size=3, padding=1), # -> 32x14x14
            nn.ReLU(),
            nn.MaxPool2d(2),                             # -> 32x7x7
        )
        self.classifier = nn.Sequential(
            nn.Flatten(),                                # 32*7*7 = 1568 valores
            nn.Linear(32 * 7 * 7, 128),                  # capa densa intermedia
            nn.ReLU(),
            nn.Dropout(0.3),                             # regularización
            nn.Linear(128, 10),                          # 10 logits, uno por clase
        )

    def forward(self, x):
        return self.classifier(self.features(x))        # extraer -> clasificar

# --- Entrenamiento de la CNN propia ---
train_a, test_a = cargar(tf_cnn)
train_dl_a = DataLoader(train_a, batch_size=64, shuffle=True)
test_dl_a = DataLoader(test_a, batch_size=256)

print("\n=== MODELO A: CNN propia ===")
cnn = MiniCNN().to(device)
acc_cnn, t_cnn = entrenar(cnn, train_dl_a, test_dl_a, epochs=5, lr=1e-3)
print(f"CNN propia -> accuracy test: {acc_cnn:.3f} en {t_cnn:.0f} s")

Salida esperada (CPU moderna; los tiempos varían):

=== MODELO A: CNN propia ===
  parámetros entrenables: 206,922
  época 1 | train loss 0.6721 acc 0.762 | test acc 0.835
  época 2 | train loss 0.4093 acc 0.853 | test acc 0.859
  época 3 | train loss 0.3452 acc 0.876 | test acc 0.873
  época 4 | train loss 0.3085 acc 0.888 | test acc 0.881
  época 5 | train loss 0.2793 acc 0.899 | test acc 0.887
CNN propia -> accuracy test: 0.887 en 95 s

Paso 4 — Modelo B: ResNet18 preentrenada (feature extraction)

from torchvision.models import resnet18, ResNet18_Weights

print("\n=== MODELO B: ResNet18 preentrenada (feature extraction) ===")
# Descarga los pesos entrenados con 1.2M de imágenes de ImageNet
resnet = resnet18(weights=ResNet18_Weights.IMAGENET1K_V1)

for p in resnet.parameters():                         # CONGELAMOS todo el tronco:
    p.requires_grad = False                           # sus pesos no se tocarán

# Sustituimos la cabeza final (fc): de 1000 clases de ImageNet a nuestras 10.
# La capa nueva nace con requires_grad=True: es lo ÚNICO que se entrena.
resnet.fc = nn.Linear(resnet.fc.in_features, 10)      # in_features = 512
resnet = resnet.to(device)

train_b, test_b = cargar(tf_resnet, n_train=3000, n_test=1500)  # menos imágenes:
train_dl_b = DataLoader(train_b, batch_size=32, shuffle=True)   # 224x224 pesa más
test_dl_b = DataLoader(test_b, batch_size=64)

acc_rn, t_rn = entrenar(resnet, train_dl_b, test_dl_b, epochs=3, lr=1e-3)
print(f"ResNet18 (cabeza) -> accuracy test: {acc_rn:.3f} en {t_rn:.0f} s")

Salida esperada:

=== MODELO B: ResNet18 preentrenada (feature extraction) ===
  parámetros entrenables: 5,130
  época 1 | train loss 0.5960 acc 0.808 | test acc 0.856
  época 2 | train loss 0.3706 acc 0.874 | test acc 0.872
  época 3 | train loss 0.3143 acc 0.891 | test acc 0.881
ResNet18 (cabeza) -> accuracy test: 0.881 en 340 s

Paso 5 — Comparación honesta

print("\n=== COMPARACIÓN ===")
print(f"{'modelo':<22}{'acc test':>10}{'tiempo':>10}{'params entrenados':>20}")
print(f"{'CNN propia':<22}{acc_cnn:>10.3f}{t_cnn:>9.0f}s{'206.922':>20}")
print(f"{'ResNet18 (cabeza)':<22}{acc_rn:>10.3f}{t_rn:>9.0f}s{'5.130':>20}")
Modelo Acc. test Tiempo (CPU) Datos usados Parámetros entrenados
CNN propia ~0.89 ~95 s 6000 206.922
ResNet18 (feature extraction) ~0.88 ~340 s 3000 5.130

Nota — interpretación

en FashionMNIST (imágenes 28×28 muy distintas a las

fotos de ImageNet) la CNN propia compite de tú a tú. La lección real es doble: (1) la ResNet alcanza esa cifra entrenando 40× menos parámetros y con la mitad de datos — con datasets pequeños de fotos reales (donde ImageNet sí se parece al dominio) el transfer learning arrasa; (2) en CPU, redimensionar a 224×224 encarece cada época — el coste del transfer está en el forward del tronco congelado. Con GPU, la ResNet termina en ~1 minuto.

Paso 6 — Matriz de confusión

from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay

@torch.no_grad()
def predecir_todo(model, dl):
    """Devuelve (y_verdadero, y_predicho) para todo el DataLoader."""
    model.eval()
    ys, ps = [], []
    for xb, yb in dl:
        ys.append(yb)                                 # etiquetas reales
        ps.append(model(xb.to(device)).argmax(1).cpu())  # predicciones
    return torch.cat(ys).numpy(), torch.cat(ps).numpy()

y_true, y_pred = predecir_todo(cnn, test_dl_a)        # usamos la CNN propia
cm = confusion_matrix(y_true, y_pred)                 # matriz 10x10
disp = ConfusionMatrixDisplay(cm, display_labels=CLASES)
fig, ax = plt.subplots(figsize=(9, 8))
disp.plot(ax=ax, cmap="Blues", xticks_rotation=45, colorbar=False)
plt.title("Matriz de confusión — CNN propia (test)")
plt.tight_layout(); plt.savefig("matriz_confusion.png", dpi=120); plt.show()

Observa la diagonal fuerte y los focos de confusión típicos: Camisa ↔ Camiseta ↔ Jersey ↔ Abrigo (prendas superiores que hasta un humano confunde en 28×28) y Zapatilla ↔ Botín ↔ Sandalia.

Paso 7 — Inspección de errores

# Índices de las imágenes mal clasificadas
errores = np.where(y_true != y_pred)[0]
print(f"Errores en test: {len(errores)} de {len(y_true)} "
      f"({len(errores)/len(y_true):.1%})")

# Mostramos las 12 primeras con etiqueta real vs predicha
fig, axes = plt.subplots(3, 4, figsize=(10, 8))
for ax, idx in zip(axes.ravel(), errores[:12]):
    img, _ = test_a[idx]                              # tensor normalizado (1,28,28)
    ax.imshow(img.squeeze(), cmap="gray")             # quitamos el canal y pintamos
    ax.set_title(f"real: {CLASES[y_true[idx]]}\npred: {CLASES[y_pred[idx]]}",
                 fontsize=9)
    ax.axis("off")
plt.suptitle("Imágenes mal clasificadas")
plt.tight_layout(); plt.savefig("errores.png", dpi=120); plt.show()

Salida esperada:

Errores en test: 170 de 1500 (11.3%)

Consejo profesional

mirar los errores uno a uno es la actividad de mayor ROI

en visión por computador. Muchas veces descubres que "los errores" son etiquetas mal puestas en el dataset, o casos genuinamente ambiguos donde el 100% es imposible.

Errores comunes

Error (mensaje real) Causa Solución
RuntimeError: Given groups=1, weight of size [64, 3, 7, 7], expected input[32, 1, 224, 224] to have 3 channels, but got 1 channels instead ResNet espera 3 canales y le pasas imágenes en gris Añade transforms.Grayscale(num_output_channels=3) al pipeline de ResNet
urllib.error.URLError: <urlopen error [SSL: CERTIFICATE_VERIFY_FAILED]...> Certificados corporativos interceptando la descarga Actualiza certifi (pip install -U certifi) o descarga el dataset manualmente en ./data
Cada época de ResNet tarda >10 min en CPU Batch 224×224 muy costoso sin GPU Reduce n_train a 1500, o pásate a Colab con GPU
RuntimeError: CUDA out of memory. Tried to allocate ... (Colab) Batch de 224×224 demasiado grande para la T4 Baja batch_size de la ResNet a 16
Accuracy de ResNet estancada en ~0.10 Olvidaste sustituir resnet.fc o normalizaste con estadísticas equivocadas Verifica resnet.fc = nn.Linear(512, 10) y las medias/std de ImageNet
ValueError: num_samples should be a positive integer value, but got num_samples=0 Subset(range(...)) vacío por un typo en los índices Comprueba len(train) antes de crear el DataLoader
La CNN entrena pero test acc ~0.10 Mezclaste transformaciones: entrenas con tf_cnn y evalúas con tf_resnet Usa el mismo transform en train y test de cada modelo

Cómo depurar

  1. Visualiza un batch antes de entrenar: plt.imshow(xb[0].permute(1,2,0)) — si las imágenes se ven "raras", tu bug está en las transformaciones, no en la red.
  2. Verifica qué está congelado: [n for n, p in model.named_parameters() if p.requires_grad] debe devolver solo ['fc.weight', 'fc.bias'] en feature extraction.
  3. Cronometra una época pequeña (n_train=500) antes de lanzar el run completo: estima el tiempo total y decide CPU vs Colab con datos, no con fe.
  4. La primera predicción de un modelo sin entrenar debe dar accuracy ~0.10 (azar con 10 clases). Si da 0.0 exacto, sospecha de las etiquetas.

Para ir más lejos

  • Fine-tuning completo: descongela todo (requires_grad=True) con lr=1e-4 (¡10× menor!) y compara con feature extraction. Con GPU merece la pena.
  • Descongelado gradual: entrena la cabeza 2 épocas, luego descongela layer4 con lr diferenciado por grupo de parámetros en el optimizador.
  • Añade data augmentation (RandomHorizontalFlip, RandomRotation(10)) solo en train y mide el efecto en el gap train/test.
  • Prueba un modelo moderno más eficiente: mobilenet_v3_small — compara accuracy y tiempo con ResNet18.

LAB 4 — Construye tu mini-GPT

Objetivo

Implementar y entrenar, pieza a pieza, un Transformer decoder-only a nivel de carácter (la misma arquitectura que GPT, en miniatura): tokenizer de caracteres, embeddings de token y de posición, bloque con atención causal multi-cabeza, entrenamiento sobre un texto en español embebido en el lab, y generación con temperatura. Verás con tus propios ojos cómo el texto generado pasa de sopa de letras a español reconocible.

Tiempo estimado: 120 minutos.

Requisitos e instalación

pip install torch==2.7.* --index-url https://download.pytorch.org/whl/cpu

Solo PyTorch. Todo lo demás lo escribimos nosotros. El modelo es tan pequeño (~200K parámetros) que entrena en CPU en unos minutos.

Nota

este lab implementa en código ejecutable lo que el capítulo 6 explica

con diagramas. Ten el capítulo a mano: cada clase de aquí corresponde a una sección de allí.

Paso 1 — El corpus: texto en español embebido

Crea lab4_gpt/minigpt.py. Nuestro corpus son ~2000 caracteres sobre la propia historia del deep learning (multiplicado para dar más señal):

# minigpt.py — Un GPT de juguete a nivel de carácter, desde cero
import math
import torch
import torch.nn as nn
import torch.nn.functional as F

torch.manual_seed(42)
device = "cuda" if torch.cuda.is_available() else "cpu"

TEXTO = """Las redes neuronales aprenden ajustando millones de pequenos numeros
llamados pesos. Cada peso se corrige un poco en la direccion que reduce el error,
y ese proceso repetido millones de veces produce sistemas capaces de ver, leer y
escribir. El descenso de gradiente es el motor del aprendizaje profundo: calcula
la pendiente del error y da un paso cuesta abajo. La retropropagacion reparte la
culpa del error entre todas las capas de la red, desde la salida hasta la entrada.
Las redes convolucionales miran las imagenes por trozos y detectan bordes, texturas
y formas cada vez mas complejas. Las redes recurrentes leen las secuencias paso a
paso y guardan un resumen en su memoria interna. El transformer cambio las reglas
del juego: en lugar de leer paso a paso, atiende a todas las palabras a la vez y
decide cuales importan para cada prediccion. La atencion es una media ponderada
donde los pesos los decide la propia red. Un modelo de lenguaje solo hace una cosa:
predecir el siguiente simbolo. De esa tarea tan simple, repetida sobre montanas de
texto, emergen la gramatica, los hechos y hasta cierto razonamiento. Entrenar es
comprimir la experiencia en pesos; generar es descomprimirla un simbolo cada vez.
El aprendizaje profundo no memoriza reglas escritas por humanos: las descubre solo
a partir de ejemplos, y esa es su fuerza y tambien su misterio.
""" * 4                                              # x4: mas datos para entrenar

print(f"Longitud del corpus: {len(TEXTO)} caracteres")

Salida esperada:

Longitud del corpus: 5344 caracteres

Nota

el texto evita tildes y eñes a propósito: menos vocabulario → el modelo

de juguete aprende más rápido. Los GPT reales usan subpalabras (BPE) precisamente para manejar cualquier carácter con un vocabulario manejable.

Paso 2 — Tokenizer de caracteres

# Vocabulario: los caracteres únicos del texto, ordenados
chars = sorted(set(TEXTO))                            # lista de caracteres únicos
vocab_size = len(chars)
stoi = {ch: i for i, ch in enumerate(chars)}          # carácter -> entero
itos = {i: ch for ch, i in stoi.items()}              # entero -> carácter

def encode(s):
    """Texto -> lista de enteros."""
    return [stoi[c] for c in s]

def decode(ids):
    """Lista de enteros -> texto."""
    return "".join(itos[i] for i in ids)

print(f"Vocabulario ({vocab_size}): {''.join(chars)!r}")
print(f"encode('red') = {encode('red')}")
print(f"decode(...)   = {decode(encode('red'))!r}")

# Todo el corpus como un único tensor de enteros
data = torch.tensor(encode(TEXTO), dtype=torch.long)
n_train = int(0.9 * len(data))                        # 90% train, 10% val
train_data, val_data = data[:n_train], data[n_train:]

Salida esperada (el orden exacto del vocabulario puede variar en los primeros símbolos):

Vocabulario (30): '\n ,.:;abcdefghijlmnopqrstuvxyz'
encode('red') = [23, 10, 9]
decode(...)   = 'red'

Paso 3 — Batches de contexto y objetivo

El modelo aprende a predecir el siguiente carácter en cada posición:

BLOCK_SIZE = 64                                       # longitud máxima de contexto

def get_batch(split, batch_size=32):
    """Devuelve (x, y): x es contexto, y es x desplazado un carácter."""
    src = train_data if split == "train" else val_data
    # posiciones de inicio aleatorias (dejando sitio para block_size+1)
    ix = torch.randint(len(src) - BLOCK_SIZE - 1, (batch_size,))
    x = torch.stack([src[i:i + BLOCK_SIZE] for i in ix])        # (B, T)
    y = torch.stack([src[i + 1:i + BLOCK_SIZE + 1] for i in ix])  # (B, T) desplazado
    return x.to(device), y.to(device)

xb, yb = get_batch("train")
print(f"x: {tuple(xb.shape)}  y: {tuple(yb.shape)}")
print(f"contexto: {decode(xb[0][:20].tolist())!r}")
print(f"objetivo: {decode(yb[0][:20].tolist())!r}")   # ¡el mismo texto corrido 1!

Salida esperada (el fragmento concreto depende del azar):

x: (32, 64)  y: (32, 64)
contexto: 'apas de la red, desd'
objetivo: 'pas de la red, desde'

Paso 4 — Atención causal multi-cabeza

La pieza estrella. Cada posición puede "mirar" solo hacia atrás (máscara causal):

class CausalSelfAttention(nn.Module):
    """Atención multi-cabeza con máscara causal, como en GPT."""
    def __init__(self, n_embd, n_head):
        super().__init__()
        assert n_embd % n_head == 0                   # dividir en cabezas exactas
        self.n_head = n_head
        self.head_dim = n_embd // n_head              # dimensión por cabeza
        # Una sola proyección lineal produce Q, K y V a la vez (3*n_embd)
        self.qkv = nn.Linear(n_embd, 3 * n_embd)
        self.proj = nn.Linear(n_embd, n_embd)         # mezcla final de cabezas
        # Máscara triangular inferior: posición t solo ve posiciones <= t
        self.register_buffer("mask",
            torch.tril(torch.ones(BLOCK_SIZE, BLOCK_SIZE)).view(1, 1, BLOCK_SIZE, BLOCK_SIZE))

    def forward(self, x):
        B, T, C = x.shape                             # batch, tiempo, canales
        qkv = self.qkv(x)                             # (B, T, 3C)
        q, k, v = qkv.split(C, dim=2)                 # tres tensores (B, T, C)
        # Reorganizamos a (B, n_head, T, head_dim) para atención por cabeza
        q = q.view(B, T, self.n_head, self.head_dim).transpose(1, 2)
        k = k.view(B, T, self.n_head, self.head_dim).transpose(1, 2)
        v = v.view(B, T, self.n_head, self.head_dim).transpose(1, 2)

        # Puntuaciones: q·k^T escalado por raiz(head_dim) -> (B, nh, T, T)
        att = (q @ k.transpose(-2, -1)) / math.sqrt(self.head_dim)
        # Máscara causal: donde mask==0 (futuro) ponemos -inf antes del softmax
        att = att.masked_fill(self.mask[:, :, :T, :T] == 0, float("-inf"))
        att = F.softmax(att, dim=-1)                  # pesos de atención (suman 1)
        out = att @ v                                 # media ponderada de valores
        out = out.transpose(1, 2).contiguous().view(B, T, C)  # re-unir cabezas
        return self.proj(out)                         # proyección de salida

Advertencia

sin la máscara causal el modelo "haría trampas" viendo el

carácter que debe predecir: la loss de entrenamiento bajaría a casi 0 al instante y la generación sería basura. Si ves ese síntoma, revisa el masked_fill.

Paso 5 — El bloque Transformer y el modelo completo

class Block(nn.Module):
    """Bloque GPT: atención + MLP, ambos con conexión residual y pre-LayerNorm."""
    def __init__(self, n_embd, n_head):
        super().__init__()
        self.ln1 = nn.LayerNorm(n_embd)               # normaliza ANTES de atención
        self.attn = CausalSelfAttention(n_embd, n_head)
        self.ln2 = nn.LayerNorm(n_embd)               # normaliza ANTES del MLP
        self.mlp = nn.Sequential(
            nn.Linear(n_embd, 4 * n_embd),            # expansión 4x (estándar GPT)
            nn.GELU(),                                # activación suave de GPT
            nn.Linear(4 * n_embd, n_embd),            # vuelta a n_embd
        )

    def forward(self, x):
        x = x + self.attn(self.ln1(x))                # residual 1: x + atención(x)
        x = x + self.mlp(self.ln2(x))                 # residual 2: x + mlp(x)
        return x

class MiniGPT(nn.Module):
    """Decoder-only a nivel de carácter: embeddings -> bloques -> logits."""
    def __init__(self, vocab_size, n_embd=64, n_head=4, n_layer=3):
        super().__init__()
        self.tok_emb = nn.Embedding(vocab_size, n_embd)   # id -> vector
        self.pos_emb = nn.Embedding(BLOCK_SIZE, n_embd)   # posición -> vector
        self.blocks = nn.Sequential(*[Block(n_embd, n_head) for _ in range(n_layer)])
        self.ln_f = nn.LayerNorm(n_embd)                  # norma final
        self.head = nn.Linear(n_embd, vocab_size)         # vector -> logits vocab

    def forward(self, idx, targets=None):
        B, T = idx.shape
        pos = torch.arange(T, device=idx.device)          # [0, 1, ..., T-1]
        x = self.tok_emb(idx) + self.pos_emb(pos)         # suma token + posición
        x = self.blocks(x)                                # los 3 bloques Transformer
        x = self.ln_f(x)
        logits = self.head(x)                             # (B, T, vocab_size)
        loss = None
        if targets is not None:                           # en entrenamiento:
            # aplanamos (B,T,V)->(B*T,V) y (B,T)->(B*T) para CrossEntropy
            loss = F.cross_entropy(logits.view(-1, logits.size(-1)),
                                   targets.view(-1))
        return logits, loss

model = MiniGPT(vocab_size).to(device)
n_params = sum(p.numel() for p in model.parameters())
print(f"MiniGPT con {n_params:,} parámetros")

Salida esperada:

MiniGPT con 203,998 parámetros

(La cifra exacta depende de vocab_size; debe rondar los 200K.)

Paso 6 — Generación con temperatura

La escribimos ANTES de entrenar para poder comparar el modelo virgen con el entrenado:

@torch.no_grad()
def generar(model, inicio="el ", max_nuevos=300, temperatura=0.8):
    """Genera texto carácter a carácter, muestreando con temperatura."""
    model.eval()
    idx = torch.tensor([encode(inicio)], device=device)  # (1, len(inicio))
    for _ in range(max_nuevos):
        ctx = idx[:, -BLOCK_SIZE:]                    # recorta al contexto máximo
        logits, _ = model(ctx)                        # forward
        logits = logits[:, -1, :] / temperatura       # SOLO la última posición
        probs = F.softmax(logits, dim=-1)             # logits -> probabilidades
        siguiente = torch.multinomial(probs, 1)       # muestreo aleatorio ponderado
        idx = torch.cat([idx, siguiente], dim=1)      # añade el carácter elegido
    model.train()
    return decode(idx[0].tolist())

print("--- ANTES de entrenar (época 0) ---")
print(generar(model, max_nuevos=150))

Salida esperada (aleatoria, pero será sopa de caracteres parecida a esto):

--- ANTES de entrenar (época 0) ---
el zjx,o
qhtvbe;ncdiu.a:xml jrp,zg
sfyoq;bt v.ehna,cxjd uromil:pgzt,eq shyf
vb.aoncx;m djriu,pglzt eqoshy

Paso 7 — Entrenamiento (y ver el texto mejorar)

optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4)  # el lr "mágico" de GPT

@torch.no_grad()
def loss_media(split, iters=50):
    """Loss promediada sobre varios batches para una estimación estable."""
    model.eval()
    losses = [model(*get_batch(split))[1].item() for _ in range(iters)]
    model.train()
    return sum(losses) / len(losses)

PASOS = 3000                                          # iteraciones de entrenamiento
for paso in range(1, PASOS + 1):
    xb, yb = get_batch("train")                       # batch aleatorio
    _, loss = model(xb, yb)                           # forward con pérdida
    optimizer.zero_grad()
    loss.backward()
    optimizer.step()
    if paso % 500 == 0 or paso == 1:
        tr, va = loss_media("train"), loss_media("val")
        print(f"paso {paso:4d} | train loss {tr:.3f} | val loss {va:.3f}")
        if paso in (500, PASOS):                      # muestra generación intermedia
            print(f"  muestra: {generar(model, max_nuevos=100)!r}\n")

Salida esperada — observa la loss caer y el texto organizarse:

paso    1 | train loss 3.379 | val loss 3.381
paso  500 | train loss 2.101 | val loss 2.132
  muestra: 'el la res la de pesde lo predien de las los cas de la reda pa so el aprentre en la ras redes lo ent'

paso 1000 | train loss 1.541 | val loss 1.630
paso 1500 | train loss 1.192 | val loss 1.322
paso 2000 | train loss 0.943 | val loss 1.115
paso 2500 | train loss 0.781 | val loss 0.994
paso 3000 | train loss 0.667 | val loss 0.920
  muestra: 'el transformer cambio las reglas del juego: en lugar de leer paso a paso, atiende a todas las palab'

Compara época 1 vs final: al paso 500 el modelo ya produce "palabras" españolas plausibles pero sin sentido; al paso 3000 reproduce frases enteras del corpus con gramática correcta. Con un corpus tan pequeño el modelo acaba memorizando (fíjate en el gap creciente train/val): exactamente lo esperable, y la razón por la que los GPT reales entrenan con billones de tokens.

Paso 8 — Jugar con la temperatura

for temp in (0.3, 0.8, 1.5):
    print(f"\n--- temperatura = {temp} ---")
    print(generar(model, inicio="la atencion ", max_nuevos=150, temperatura=temp))

Salida esperada (cualitativamente):

--- temperatura = 0.3 ---
la atencion es una media ponderada donde los pesos los decide la propia red. un
modelo de lenguaje solo hace una cosa: predecir el siguiente simbolo.

--- temperatura = 0.8 ---
la atencion es una media ponderada donde los pesos de la red, desde la salida
hasta la entrada. las redes convolucionales miran las imagenes por trozos

--- temperatura = 1.5 ---
la atencion cambion; lezas armiten cinde pama el arror des lentua nutos de vez
peso, lue predecir mose emesten trible

Nota

temperatura baja = conservador y repetitivo (casi copia el corpus);

temperatura alta = creativo pero incoherente. Es el mismo parámetro temperature que configurarás en las APIs de LLMs del módulo 04.

Errores comunes

Error (mensaje real) Causa Solución
KeyError: 'á' en encode() Intentas generar con un prompt que contiene caracteres fuera del vocabulario Usa solo caracteres presentes en TEXTO (sin tildes ni mayúsculas)
RuntimeError: The size of tensor a (128) must match the size of tensor b (64) at non-singleton dimension 1 Pasas una secuencia más larga que BLOCK_SIZE al modelo Recorta con idx[:, -BLOCK_SIZE:] como hace generar()
IndexError: index out of range in self en nn.Embedding Un id de token ≥ vocab_size (corpus cambiado sin regenerar vocabulario) Regenera stoi/itos cada vez que cambies TEXTO
La loss de train baja a ~0.01 en 100 pasos Falta la máscara causal: el modelo ve el futuro Revisa el masked_fill con la máscara triangular
La loss se queda en ~3.4 (= ln(30)) sin bajar El modelo predice uniforme: lr demasiado bajo, o los targets no están desplazados Verifica y = src[i+1 : i+BLOCK_SIZE+1] y lr=3e-4
RuntimeError: expected scalar type Long but found Float Los índices de tokens deben ser torch.long torch.tensor(encode(...), dtype=torch.long)
Generación en bucle infinito de la misma frase Temperatura muy baja + corpus memorizado Sube la temperatura o amplía el corpus

Cómo depurar

  1. Verifica la máscara visualmente: print(model.blocks[0].attn.mask[0,0,:5,:5]) debe ser triangular inferior de unos.
  2. Test de sobreajuste express: entrena con TEXTO = "hola mundo " * 50. En ~300 pasos debe generar "hola mundo hola mundo..." perfecto. Si no, hay bug.
  3. Comprueba el desplazamiento x/y: decode(x[0]) y decode(y[0]) deben ser el mismo texto corrido exactamente un carácter.
  4. Inspecciona los pesos de atención: en forward, guarda att y comprueba que cada fila suma 1 (att.sum(-1) ≈ 1) y que la parte superior derecha es 0.
  5. La loss inicial debe ser ≈ ln(vocab_size) (aquí ln(30) ≈ 3.4): es la pérdida de predecir al azar. Si empieza mucho más alta, revisa la inicialización.

Para ir más lejos

  • Sustituye tu atención manual por F.scaled_dot_product_attention(q, k, v, is_causal=True): misma matemática, kernel optimizado (FlashAttention). Mide la diferencia de velocidad.
  • Añade dropout (0.1) en atención y MLP y observa el efecto sobre el gap train/val.
  • Escala: n_embd=128, n_layer=6, BLOCK_SIZE=128 con un corpus mayor (descarga un libro de dominio público de Project Gutenberg en español). En Colab con GPU entrena en ~15 min.
  • Implementa top-k sampling (además de temperatura): logits[logits < v[-1]] = -inf con v = torch.topk(logits, k).
  • Compara con el nanoGPT de Karpathy: tu código es esencialmente el mismo. Léelo y encuentra las tres optimizaciones que él añade.

LAB 5 — Fine-tuning y embeddings con Hugging Face

Objetivo

Usar el ecosistema Hugging Face de principio a fin: pipeline() de análisis de sentimiento en español, exploración del tokenizer de subpalabras, fine-tuning de un modelo multilingüe pequeño sobre un dataset inline de tickets de soporte con Trainer, y búsqueda semántica con sentence-transformers sobre 30 frases.

Tiempo estimado: 90-120 minutos.

Requisitos e instalación

pip install transformers==4.53.* datasets==3.6.* accelerate==1.7.* \
            sentence-transformers==4.1.* scikit-learn==1.6.* matplotlib==3.10.*
# PyTorch del LAB 2/3 sirve; si no lo tienes:
pip install torch==2.7.* --index-url https://download.pytorch.org/whl/cpu

Advertencia (descargas de modelos)

este lab descarga varios cientos de MB

de modelos desde el Hub de Hugging Face la primera vez (se cachean en ~/.cache/huggingface/). Con conexión lenta, planifica: el modelo de sentimiento (~500 MB), DistilBERT multilingüe (~540 MB) y el modelo de embeddings (~470 MB).

Nota (Colab)

en Colab todo va más fluido: transformers y datasets vienen

casi listos y la descarga usa la red de Google. Con GPU T4 el fine-tuning del Paso 3 baja de ~10 min (CPU) a ~1 min.

Paso 1 — pipeline(): sentimiento en español en 3 líneas

Crea lab5_hf/sentimiento.py:

# sentimiento.py — El ecosistema Hugging Face de punta a punta
from transformers import pipeline

# Modelo entrenado para sentimiento multilingüe (1 a 5 estrellas)
clf = pipeline("sentiment-analysis",
               model="nlptown/bert-base-multilingual-uncased-sentiment")

frases = [
    "Este producto es una maravilla, lo recomiendo sin dudarlo",
    "El envío llegó tarde y la caja estaba destrozada",
    "Cumple su función, sin más",
]
for frase, res in zip(frases, clf(frases)):
    print(f"{res['label']:8s} ({res['score']:.2f}) <- {frase}")

Salida esperada:

5 stars  (0.79) <- Este producto es una maravilla, lo recomiendo sin dudarlo
1 star   (0.62) <- El envío llegó tarde y la caja estaba destrozada
3 stars  (0.56) <- Cumple su función, sin más

Consejo profesional

pipeline() es tu herramienta de *prototipado en la

primera hora* de cualquier proyecto NLP: demuestra viabilidad al cliente antes de invertir en fine-tuning.

Paso 2 — Explorar el tokenizer: subpalabras del español

from transformers import AutoTokenizer

tok = AutoTokenizer.from_pretrained("distilbert-base-multilingual-cased")

for palabra in ["hola", "electrodoméstico", "desafortunadamente", "facturación",
                "no funciona la aplicación"]:
    tokens = tok.tokenize(palabra)                    # texto -> subpalabras
    ids = tok.encode(palabra)                         # con [CLS] y [SEP] añadidos
    print(f"{palabra!r:38} -> {tokens}  ({len(ids)} ids con especiales)")

Salida esperada:

'hola'                                 -> ['hola']  (3 ids con especiales)
'electrodoméstico'                     -> ['electro', '##dom', '##és', '##tico']  (6 ids con especiales)
'desafortunadamente'                   -> ['des', '##af', '##ort', '##una', '##damente']  (7 ids con especiales)
'facturación'                          -> ['factura', '##ción']  (4 ids con especiales)
'no funciona la aplicación'            -> ['no', 'funciona', 'la', 'aplicación']  (6 ids con especiales)

Nota

los ## marcan continuación de palabra (WordPiece). Palabras

frecuentes viven enteras en el vocabulario; las raras se descomponen en piezas reutilizables. Así un vocabulario de 119.547 entradas cubre 104 idiomas sin "palabras desconocidas".

Paso 3 — Fine-tuning sobre tickets de soporte (dataset inline)

Ahora lo serio: adaptar DistilBERT multilingüe a nuestra tarea con ~100 ejemplos.

# --- Dataset inline: 100 tickets sintéticos de soporte, 4 clases ---
import random
random.seed(42)

PLANTILLAS = {
    "facturacion": [
        "Me han cobrado dos veces la cuota de {mes}",
        "La factura de {mes} tiene un importe incorrecto",
        "Quiero cambiar el método de pago de mi suscripción",
        "No me llega la factura de {mes} por correo",
        "Necesito una factura con mis datos fiscales actualizados",
    ],
    "tecnico": [
        "La aplicación se cierra sola al abrir {seccion}",
        "No puedo iniciar sesión desde {dispositivo}, da error",
        "La sincronización con {dispositivo} lleva horas fallando",
        "Error 500 al guardar cambios en {seccion}",
        "La app va lentísima desde la última actualización en {dispositivo}",
    ],
    "cuenta": [
        "Quiero cambiar el correo asociado a mi cuenta",
        "He olvidado mi contraseña y el enlace de recuperación no llega",
        "Necesito eliminar mi cuenta y todos mis datos",
        "Quiero añadir otro usuario a mi organización",
        "Mi cuenta aparece bloqueada sin motivo",
    ],
    "comercial": [
        "¿Qué diferencias hay entre el plan básico y el premium?",
        "¿Hacen descuento para {colectivo}?",
        "Quiero contratar el plan anual para mi empresa",
        "¿El plan premium incluye soporte telefónico?",
        "Me interesa una demo del producto para {colectivo}",
    ],
}
RELLENOS = {"mes": ["enero", "marzo", "junio"],
            "seccion": ["informes", "el panel", "ajustes"],
            "dispositivo": ["el móvil", "la tablet", "Windows"],
            "colectivo": ["estudiantes", "ONGs", "startups"]}

def rellenar(plantilla):
    """Sustituye cada hueco {clave} por una opción aleatoria."""
    for clave, opciones in RELLENOS.items():
        if "{" + clave + "}" in plantilla:
            plantilla = plantilla.replace("{" + clave + "}", random.choice(opciones))
    return plantilla

etiquetas = list(PLANTILLAS)                          # las 4 clases
textos, labels = [], []
for i, (clase, plantillas) in enumerate(PLANTILLAS.items()):
    for _ in range(25):                               # 25 tickets por clase = 100
        textos.append(rellenar(random.choice(plantillas)))
        labels.append(i)

print(f"{len(textos)} tickets, clases: {etiquetas}")
print("Ejemplo:", textos[0], "->", etiquetas[labels[0]])

Salida esperada:

100 tickets, clases: ['facturacion', 'tecnico', 'cuenta', 'comercial']
Ejemplo: Me han cobrado dos veces la cuota de marzo -> facturacion
# --- Fine-tuning con Trainer ---
import numpy as np
from datasets import Dataset
from transformers import (AutoModelForSequenceClassification,
                          TrainingArguments, Trainer)
from sklearn.metrics import accuracy_score, f1_score

# Dataset HF a partir de listas, con split train/test estratificado por semilla
ds = Dataset.from_dict({"text": textos, "label": labels})
ds = ds.train_test_split(test_size=0.2, seed=42)      # 80 train / 20 test

def tokenizar(batch):
    """Tokeniza cada texto con truncado y padding a 64 tokens."""
    return tok(batch["text"], truncation=True, padding="max_length", max_length=64)

ds_tok = ds.map(tokenizar, batched=True)              # aplica a todo el dataset

# Modelo base + cabeza de clasificación NUEVA con 4 salidas
model = AutoModelForSequenceClassification.from_pretrained(
    "distilbert-base-multilingual-cased",
    num_labels=4,                                     # nuestras 4 clases
    id2label=dict(enumerate(etiquetas)),              # para salidas legibles
    label2id={e: i for i, e in enumerate(etiquetas)})

def metricas(eval_pred):
    """Accuracy y F1 macro para el Trainer."""
    logits, y = eval_pred
    pred = np.argmax(logits, axis=-1)                 # clase más probable
    return {"accuracy": accuracy_score(y, pred),
            "f1_macro": f1_score(y, pred, average="macro")}

args = TrainingArguments(
    output_dir="./resultados_tickets",                # dónde guardar checkpoints
    num_train_epochs=8,                               # pocas: dataset diminuto
    per_device_train_batch_size=16,
    learning_rate=3e-5,                               # lr pequeño: fine-tuning
    eval_strategy="epoch",                            # evalúa al final de cada época
    logging_steps=10,
    report_to="none",                                 # sin wandb/tensorboard
)

trainer = Trainer(model=model, args=args,
                  train_dataset=ds_tok["train"],
                  eval_dataset=ds_tok["test"],
                  compute_metrics=metricas)
trainer.train()                                       # ~10 min CPU / ~1 min GPU
print(trainer.evaluate())

Salida esperada (resumida; verás una época por línea):

{'eval_loss': 1.3421, 'eval_accuracy': 0.45, 'eval_f1_macro': 0.41, 'epoch': 1.0}
{'eval_loss': 0.9847, 'eval_accuracy': 0.80, 'eval_f1_macro': 0.79, 'epoch': 2.0}
{'eval_loss': 0.5210, 'eval_accuracy': 0.90, 'eval_f1_macro': 0.90, 'epoch': 4.0}
{'eval_loss': 0.2093, 'eval_accuracy': 1.00, 'eval_f1_macro': 1.00, 'epoch': 8.0}
{'eval_loss': 0.2093, 'eval_accuracy': 1.00, 'eval_f1_macro': 1.00, 'epoch': 8.0}

Advertencia

¿accuracy 1.00? No te emociones: el test comparte plantillas con

el train, así que está "contaminado" por diseño. Con tickets reales (léxico libre, errores ortográficos, mezcla de temas) espera 0.85-0.92. El proyecto profesional de este módulo trata exactamente este problema con más rigor.

# --- Probar con tickets NUEVOS, no vistos ---
from transformers import pipeline as pl
clasificador = pl("text-classification", model=model, tokenizer=tok)

nuevos = ["el pago de este mes aparece duplicado en mi tarjeta",
          "la app da error al abrir el panel desde el ordenador",
          "¿tenéis precios especiales para universidades?"]
for t in nuevos:
    r = clasificador(t)[0]
    print(f"{r['label']:12s} ({r['score']:.2f}) <- {t}")

Salida esperada:

facturacion  (0.97) <- el pago de este mes aparece duplicado en mi tarjeta
tecnico      (0.95) <- la app da error al abrir el panel desde el ordenador
comercial    (0.93) <- ¿tenéis precios especiales para universidades?

Paso 4 — Búsqueda semántica con sentence-transformers

# busqueda.py — Embeddings de frases y búsqueda semántica
from sentence_transformers import SentenceTransformer, util
import matplotlib.pyplot as plt

# Modelo multilingüe compacto de embeddings de frases (384 dimensiones)
model = SentenceTransformer("paraphrase-multilingual-MiniLM-L12-v2")

FRASES = [
    # deportes
    "El equipo local ganó el partido en el último minuto",
    "La maratón de la ciudad batió récord de participantes",
    "El tenista español se clasificó para la final",
    "El entrenador anunció su retirada tras veinte años",
    "La selección femenina de baloncesto logró el oro",
    "El ciclista abandonó la carrera por una caída",
    # cocina
    "La paella necesita un buen caldo de pescado",
    "El secreto de esta tarta es la mantequilla fría",
    "Añade el ajo cuando la cebolla esté transparente",
    "El pan casero requiere un buen amasado y paciencia",
    "Esta receta de lentejas era de mi abuela",
    "El horno debe estar precalentado a 180 grados",
    # tecnología
    "El nuevo portátil tiene una batería de veinte horas",
    "La actualización del sistema corrige fallos de seguridad",
    "El teléfono se carga por completo en media hora",
    "La inteligencia artificial transforma el diagnóstico médico",
    "El servidor estuvo caído durante la madrugada",
    "Las gafas de realidad aumentada llegan al mercado",
    # viajes
    "El vuelo a Buenos Aires sale con dos horas de retraso",
    "La playa estaba desierta al amanecer",
    "Reservamos un hotel con vistas a la montaña",
    "El tren nocturno cruza tres países hasta Viena",
    "Perdimos la conexión en el aeropuerto de Madrid",
    "El casco antiguo se recorre mejor a pie",
    # economía
    "La inflación bajó dos décimas el mes pasado",
    "El banco central subió los tipos de interés",
    "Las exportaciones crecieron un cinco por ciento",
    "La empresa presentó pérdidas por tercer año",
    "El precio de la vivienda sigue subiendo en las capitales",
    "Los mercados reaccionaron con calma al anuncio",
]

# Codificamos las 30 frases: cada una se convierte en un vector de 384 números
emb = model.encode(FRASES, convert_to_tensor=True, normalize_embeddings=True)
print(f"Matriz de embeddings: {tuple(emb.shape)}")    # (30, 384)

# Matriz de similitud coseno 30x30 (con vectores normalizados = producto escalar)
sim = util.cos_sim(emb, emb)

plt.figure(figsize=(8, 7))
plt.imshow(sim.cpu(), cmap="viridis")                 # bloques 6x6 por tema
plt.colorbar(label="similitud coseno")
plt.title("Matriz de similitud entre 30 frases (bloques = temas)")
plt.xlabel("frase"); plt.ylabel("frase")
plt.tight_layout(); plt.savefig("matriz_similitud.png", dpi=120); plt.show()

Salida esperada:

Matriz de embeddings: (30, 384)

En la imagen verás 5 bloques brillantes de 6×6 en la diagonal: las frases de un mismo tema son similares entre sí aunque no compartan ni una palabra.

# --- Búsqueda: top-k frases más cercanas a una consulta ---
def buscar(consulta, k=3):
    """Imprime las k frases más similares a la consulta."""
    q = model.encode(consulta, convert_to_tensor=True, normalize_embeddings=True)
    scores = util.cos_sim(q, emb)[0]                  # similitud con las 30 frases
    top = scores.topk(k)                              # los k mejores
    print(f"\nConsulta: {consulta!r}")
    for score, idx in zip(top.values, top.indices):
        print(f"  {score:.3f}  {FRASES[idx]}")

buscar("recetas tradicionales de mi familia")
buscar("problemas con un ordenador")
buscar("resultado de una competición deportiva")

Salida esperada:

Consulta: 'recetas tradicionales de mi familia'
  0.531  Esta receta de lentejas era de mi abuela
  0.404  El pan casero requiere un buen amasado y paciencia
  0.383  La paella necesita un buen caldo de pescado

Consulta: 'problemas con un ordenador'
  0.514  El servidor estuvo caído durante la madrugada
  0.478  La actualización del sistema corrige fallos de seguridad
  0.441  El nuevo portátil tiene una batería de veinte horas

Consulta: 'resultado de una competición deportiva'
  0.567  El equipo local ganó el partido en el último minuto
  0.512  El tenista español se clasificó para la final
  0.489  La selección femenina de baloncesto logró el oro

Caso empresarial

acabas de construir el núcleo de un buscador semántico:

lo mismo que alimenta FAQs inteligentes, deduplicación de tickets y la "R" de los sistemas RAG que verás en el módulo 04.

Errores comunes

Error (mensaje real) Causa Solución
OSError: We couldn't connect to 'https://huggingface.co' to load this file Sin conexión, proxy corporativo, o el Hub caído Verifica la red; configura HF_HUB_OFFLINE=1 si ya tienes el modelo cacheado
ImportError: Using the 'Trainer' with 'PyTorch' requires 'accelerate>=0.26.0' Falta la dependencia accelerate pip install accelerate
TypeError: TrainingArguments.__init__() got an unexpected keyword argument 'evaluation_strategy' Cambio de nombre del argumento en transformers ≥ 4.46 Usa eval_strategy="epoch" (nombre nuevo)
Some weights of DistilBertForSequenceClassification were not initialized from the model checkpoint... You should probably TRAIN this model NO es un error: la cabeza de clasificación es nueva y aleatoria Ignóralo: el fine-tuning entrena exactamente esa cabeza
RuntimeError: The size of tensor a (512) must match the size of tensor b... Textos más largos que max_length sin truncation=True Añade truncation=True al tokenizar
El fine-tuning tarda >30 min en CPU Normal con modelos de 135M de parámetros Reduce num_train_epochs a 4 o usa Colab con GPU
ValueError: expected sequence of length 12 at dim 1 (got 15) Batch sin padding uniforme Usa padding="max_length" o un DataCollatorWithPadding
Los scores de la búsqueda semántica salen > 1 o negativos raros Olvidaste normalize_embeddings=True Normaliza al codificar (coseno bien definido en [-1, 1])

Cómo depurar

  1. Aísla la descarga: ejecuta primero solo AutoTokenizer.from_pretrained(...) en un script mínimo. Si eso falla, tu problema es de red/proxy, no de código.
  2. Inspecciona un batch tokenizado: print(ds_tok["train"][0]) — debe tener input_ids, attention_mask y label. Si falta label, Trainer no entrenará nada y la loss no existirá.
  3. Overfitea 8 ejemplos: entrena con ds_tok["train"].select(range(8)) durante 20 épocas; accuracy de train debe llegar a 1.0. Es el smoke test del fine-tuning.
  4. Mira la loss inicial: con 4 clases debe empezar cerca de ln(4) ≈ 1.39.
  5. Si sospechas del entorno, imprime versiones: import transformers, torch; print(transformers.__version__, torch.__version__).

Para ir más lejos

  • Congela el cuerpo de DistilBERT (for p in model.distilbert.parameters(): p.requires_grad = False) y entrena solo la cabeza: compara accuracy y tiempo.
  • Compara tu fine-tuning con zero-shot: pipeline("zero-shot-classification", model="joeddav/xlm-roberta-large-xnli") con las 4 etiquetas como hipótesis. ¿Quién gana con 100 ejemplos?
  • Sube tu modelo al Hub (trainer.push_to_hub()) con una cuenta gratuita: así se comparte un modelo en el mundo real.
  • Usa los embeddings de sentence-transformers como features de una regresión logística de scikit-learn (módulo 02): es el clasificador "barato" más fuerte que existe para pocos datos.
  • Explora huggingface.co/models filtrando por es y text-classification: aprende a leer una model card con ojo crítico.

Checklist de salida del módulo de laboratorios

Antes de pasar a los proyectos, deberías poder decir "sí" a todo esto:

  • [ ] Sé derivar e implementar backprop para una red de 2 capas y verificarla con gradient checking (LAB 1).
  • [ ] Escribo de memoria el training loop de PyTorch con sus 5 pasos y sé por qué existe cada uno (LAB 2).
  • [ ] Sé provocar y reconocer overfitting en las curvas, y cortarlo con early stopping (LAB 2).
  • [ ] Sé congelar un modelo preentrenado, cambiarle la cabeza y justificar cuándo compensa (LAB 3).
  • [ ] Puedo explicar la máscara causal y la temperatura de muestreo con mi propio mini-GPT como ejemplo (LAB 4).
  • [ ] He hecho fine-tuning de un Transformer con Trainer y una búsqueda semántica con embeddings (LAB 5).

Anterior Módulo Siguiente
Ejercicios 03-DEEP-LEARNING Proyectos