En 09-02 cerraste cada práctica con una decisión de negocio y con una pregunta pendiente: ¿cuándo compensa una red neuronal frente al modelo clásico bien hecho? Esta lección la responde con las manos, en PyTorch sobre CPU, con cuatro proyectos guiados que consolidan el módulo 5: el MLP de devoluciones con una búsqueda manual de arquitectura y tasa de aprendizaje (05-02, 05-03), la CNN de fotos de incidencias con una tercera clase y aumento de datos (05-04), una GRU sobre la demanda semanal del NovaClean (05-04) y un clasificador de sentimiento con embeddings entrenados desde cero (05-05). Cada proyecto se compara con la referencia clásica del módulo 4 en las mismas condiciones, y en varios la red no gana: entender por qué es el objetivo.

Cómo trabajar la lección: cada proyecto tiene pasos numerados; intenta escribir el código de cada paso antes de mirar el nuestro, ejecútalo (todos tardan segundos en CPU; el más largo, un par de minutos) y compara con las salidas, que varían ligeramente según semilla y versión de PyTorch. Necesitas novamarket_ml.py (módulo 4), resenas_nm.py (las plantillas POSITIVAS, NEGATIVAS y PRODUCTOS de 05-05), torch, scikit-learn, pandas y numpy. Tiempo orientativo: 45-60 minutos por proyecto.

Contenido

  1. Proyecto 1: MLP de devoluciones con búsqueda de arquitectura y tasa de aprendizaje
  2. Proyecto 2: CNN de fotos de incidencias con una tercera clase y aumento de datos
  3. Proyecto 3: GRU para la demanda semanal frente a las líneas base y la lineal
  4. Proyecto 4: sentimiento de reseñas con embeddings desde cero frente a la bolsa de palabras
  5. Errores Comunes y Consejos
  6. Conclusión

  1. Proyecto 1: MLP de devoluciones con búsqueda de arquitectura y tasa de aprendizaje

Recordatorio (05-03, sección 9). El MLP 21 → 16 → 8 → 1 con dropout 0,2, Adam (0,001), lotes de 64 y parada temprana con paciencia 15 sobre un conjunto de validación de 450 pedidos obtuvo AUC 0,834 en el test de 750 (el mismo de 04-05, donde la logística da 0,844). El bucle es zero_grad → backward → step, con BCEWithLogitsLoss y la sigmoide solo para leer probabilidades.

Enunciado. Marta quiere saber si un MLP mejor ajustado bate a la logística. Pasos: (1) prepara los datos exactamente como en 05-03 (train_test_split con random_state=42, luego 20 % de validación del entrenamiento; la preparación se ajusta solo con el entrenamiento); (2) escribe crear_red(capas, dropout) que construya un MLP con cualquier lista de tamaños; (3) escribe entrenar con parada temprana que devuelva la mejor época; (4) prueba al menos seis configuraciones variando capas ((16, 8), (32, 16), (64, 32, 16), (8,)), tasa (0,01, 0,001, 0,0001) y dropout, y anota en una tabla parámetros, mejor época, pérdida y AUC de validación, AUC de test y segundos; (5) elige por validación, repite la elegida con 5 semillas y compárala con la logística entrenada con los mismos 1.799 pedidos.

Solución

import numpy as np, pandas as pd, torch, torch.nn as nn, time
from torch.utils.data import TensorDataset, DataLoader
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import Pipeline
from sklearn.metrics import roc_auc_score
from novamarket_ml import generar_pedidos_ml, ensuciar_pedidos, preparar_pedidos, crear_preparacion

X, y = preparar_pedidos(ensuciar_pedidos(generar_pedidos_ml(3000, 42), 42))
Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.25, random_state=42, stratify=y)
Xtr2, Xval, ytr2, yval = train_test_split(Xtr, ytr, test_size=0.2, random_state=42, stratify=ytr)
prep = crear_preparacion().fit(Xtr2)                              # solo con entrenamiento
def a_tensor(Xd, yd):
    return torch.tensor(prep.transform(Xd).astype(np.float32)), torch.tensor(yd.values.astype(np.float32)).view(-1, 1)
Xtr_t, ytr_t = a_tensor(Xtr2, ytr2); Xval_t, yval_t = a_tensor(Xval, yval); Xte_t, yte_t = a_tensor(Xte, yte)

def crear_red(capas=(16, 8), dropout=0.2, entrada=21):
    piezas, n = [], entrada
    for h in capas:
        piezas += [nn.Linear(n, h), nn.ReLU(), nn.Dropout(dropout)]; n = h
    return nn.Sequential(*piezas, nn.Linear(n, 1))                # sin sigmoide: la pone BCEWithLogitsLoss

def evaluar(red, X_t, y_t):
    red.eval()
    with torch.no_grad():
        logits = red(X_t)
    return nn.BCEWithLogitsLoss()(logits, y_t).item(), roc_auc_score(y_t.numpy().ravel(), torch.sigmoid(logits).numpy().ravel())

def entrenar(red, tasa=0.001, epocas=300, lote=64, paciencia=15, semilla=0):
    torch.manual_seed(semilla)
    cargador = DataLoader(TensorDataset(Xtr_t, ytr_t), batch_size=lote, shuffle=True)
    opt, fn = torch.optim.Adam(red.parameters(), lr=tasa), nn.BCEWithLogitsLoss()
    mejor_val, mejor_estado, sin_mejora, mejor_ep = float("inf"), None, 0, 0
    for ep in range(1, epocas + 1):
        red.train()
        for xb, yb in cargador:
            opt.zero_grad(); fn(red(xb), yb).backward(); opt.step()
        L_val, _ = evaluar(red, Xval_t, yval_t)
        if L_val < mejor_val - 1e-4:
            mejor_val, sin_mejora, mejor_ep = L_val, 0, ep
            mejor_estado = {k: v.clone() for k, v in red.state_dict().items()}
        else:
            sin_mejora += 1
            if sin_mejora >= paciencia: break
    red.load_state_dict(mejor_estado)
    return mejor_ep, ep

configs = [((16, 8), 0.001, 0.2), ((16, 8), 0.01, 0.2), ((16, 8), 0.0001, 0.2), ((32, 16), 0.001, 0.2),
           ((64, 32, 16), 0.001, 0.3), ((8,), 0.001, 0.0), ((16, 8), 0.001, 0.0)]
filas = []
for capas, tasa, do in configs:
    t = time.time(); torch.manual_seed(42); red = crear_red(capas, do)
    mejor_ep, ult = entrenar(red, tasa=tasa)
    (Lv, auc_v), (_, auc_t) = evaluar(red, Xval_t, yval_t), evaluar(red, Xte_t, yte_t)
    filas.append([str(capas), tasa, do, sum(p.numel() for p in red.parameters()), mejor_ep, ult, round(Lv, 4), round(auc_v, 3), round(auc_t, 3), round(time.time() - t, 1)])
tabla = pd.DataFrame(filas, columns=["capas", "tasa", "dropout", "params", "mejor_ep", "ult_ep", "perdida_val", "AUC_val", "AUC_test", "seg"])
print(tabla.to_string(index=False))
mejor = tabla.sort_values("AUC_val", ascending=False).iloc[0]
aucs = []
for s in range(5):
    torch.manual_seed(s); red = crear_red(eval(mejor["capas"]), mejor["dropout"]); entrenar(red, tasa=mejor["tasa"], semilla=s)
    aucs.append(evaluar(red, Xte_t, yte_t)[1])
print("Mejor por validación:", mejor["capas"], mejor["tasa"], "| AUC test 5 semillas:", np.round(aucs, 3), round(np.mean(aucs), 3))
log = Pipeline([("prep", crear_preparacion()), ("modelo", LogisticRegression(max_iter=1000))]).fit(Xtr2, ytr2)
print("Logística (mismos 1799): AUC val", round(roc_auc_score(yval, log.predict_proba(Xval)[:, 1]), 3), "| AUC test", round(roc_auc_score(yte, log.predict_proba(Xte)[:, 1]), 3))
       capas   tasa  dropout  params  mejor_ep  ult_ep  perdida_val  AUC_val  AUC_test  seg
     (16, 8) 0.0010      0.2     497        75      90       0.3241    0.835     0.834  3.1
     (16, 8) 0.0100      0.2     497        11      26       0.3316    0.825     0.830  0.7
     (16, 8) 0.0001      0.2     497       299     300       0.3423    0.821     0.831  8.1
    (32, 16) 0.0010      0.2    1249        26      41       0.3317    0.829     0.839  1.2
(64, 32, 16) 0.0010      0.3    4033        17      32       0.3337    0.831     0.838  1.1
        (8,) 0.0010      0.0     185        97     112       0.3232    0.843     0.836  2.5
     (16, 8) 0.0010      0.0     497        49      64       0.3219    0.837     0.832  1.7
Mejor por validación: (8,) 0.001 | AUC test 5 semillas: [0.837 0.841 0.837 0.84  0.842] 0.84
Logística (mismos 1799): AUC val 0.832 | AUC test 0.842

Lectura de la tabla:

  • La primera fila reproduce 05-03 (mejor época 75, AUC test 0,834). Con tasa 0,01 la red converge en 11 épocas pero peor (0,825 en validación): pasos demasiado grandes; con 0,0001 agota las 300 épocas sin haber terminado de aprender (0,821): la tasa de aprendizaje es el hiperparámetro más sensible, como decía 05-03.
  • Las redes más grandes (1.249 y 4.033 parámetros) paran antes (época 17-26) porque empiezan a sobreajustar enseguida con 1.799 ejemplos; su AUC de test es algo mayor (0,838-0,839), pero no su AUC de validación, y elegir por test sería hacer trampa.
  • La mejor por validación es la más pequeña: una sola capa de 8 neuronas sin dropout (185 parámetros), 0,843 en validación y 0,840 ± 0,002 en test con 5 semillas. La logística con los mismos datos da 0,842. Todas las diferencias están dentro de ±0,005, el ruido de un test de 750 pedidos.
  • Decisión: la de 05-03, reforzada. En tabular pequeño con una verdad casi lineal, un MLP bien ajustado empata con la logística; ninguna configuración la bate de forma que merezca la complejidad extra. Marta se queda con la logística y guarda esta tabla como evidencia.

Retroalimentación

  • Error típico: elegir la configuración mirando AUC_test (aquí habría elegido (32, 16) con 0,839, y esa cifra ya no sería una estimación honesta). Otro: no fijar torch.manual_seed antes de crear la red y atribuir a la arquitectura lo que es azar de inicialización; por eso la elegida se repite con 5 semillas.
  • La parada temprana hace el papel del número de épocas: no lo busques a mano. Y la pérdida de validación es mejor criterio de parada que el AUC (más suave).
  • Variantes: añade weight_decay=1e-4 en Adam (regularización L2) y compara con el dropout; prueba nn.BatchNorm1d entre capas; mide el coste de negocio al umbral 0,2 (09-02) del mejor MLP frente a la logística; usa optuna o RandomizedSearchCV con skorch si quieres automatizar la búsqueda (fuera del alcance del curso).

  1. Proyecto 2: CNN de fotos de incidencias con una tercera clase y aumento de datos

Recordatorio (05-04, secciones 3-4). Las fotos sintéticas 16×16 de generar_fotos (caja clara sobre fondo oscuro; 1 = dañada con grieta diagonal o abolladura) se clasifican con una CNN de dos bloques Conv2d → ReLU → MaxPool2d y dos capas densas (9.538 parámetros, 98,7 % de acierto), mientras un MLP con más parámetros se queda en el 57 %. CrossEntropyLoss combina softmax y entropía cruzada para clases múltiples.

Enunciado. Diego quiere una tercera categoría: "caja mojada" (mancha de humedad: una zona grande de gris medio con bordes difusos, distinta de la abolladura pequeña y oscura). Pasos: (1) escribe generar_fotos3(n=900, semilla=42) que extienda el generador de forma reproducible con las clases 0 correcta, 1 dañada, 2 mojada; (2) imprime una foto de cada clase como matriz de dígitos y comprueba que se distinguen a ojo; (3) adapta la CNN a 3 salidas y entrénala 30 épocas (675 fotos de entrenamiento, 225 de test); (4) calcula la matriz de confusión y lista qué confunde; (5) escribe aumentar(xb, rng) que aplique espejos y rotaciones de 90° al azar a cada imagen del lote (las etiquetas no cambian) y compara acierto con y sin aumento con 150 fotos de entrenamiento (60 épocas) y con las 675, con 4 semillas; (6) compara con un MLP.

Solución

from sklearn.metrics import confusion_matrix
CLASES = ["correcta", "danada", "mojada"]

def generar_fotos3(n=900, semilla=42, tam=16):
    """16x16 en gris: caja clara sobre fondo oscuro. 0 = correcta, 1 = dañada (grieta/abolladura), 2 = mojada (mancha difusa)."""
    rng = np.random.default_rng(semilla)
    X, y = np.zeros((n, 1, tam, tam), dtype=np.float32), np.zeros(n, dtype=np.int64)
    for i in range(n):
        img = rng.normal(0.1, 0.05, (tam, tam))
        x0, y0 = rng.integers(1, 5, size=2); x1, y1 = rng.integers(tam - 5, tam - 1, size=2)
        img[y0:y1, x0:x1] = rng.normal(0.7, 0.05, (y1 - y0, x1 - x0))
        clase = rng.integers(0, 3); y[i] = clase
        if clase == 1:                                                     # igual que en 05-04
            if rng.random() < 0.5:
                r0, c0 = rng.integers(y0, y1 - 4), rng.integers(x0, x1 - 4); largo = rng.integers(4, min(y1 - r0, x1 - c0) + 1)
                for k in range(largo): img[r0 + k, c0 + k] = 0.05
            else:
                r0, c0 = rng.integers(y0, y1 - 3), rng.integers(x0, x1 - 3); lado = rng.integers(2, 4)
                img[r0:r0 + lado, c0:c0 + lado] = rng.normal(0.15, 0.05, (lado, lado))
        elif clase == 2:                                                   # mancha: 5-8 píxeles, gris medio, bordes suaves
            alto, ancho = rng.integers(5, 9, size=2)
            r0 = rng.integers(y0, max(y0 + 1, y1 - alto)); c0 = rng.integers(x0, max(x0 + 1, x1 - ancho))
            r1, c1 = min(r0 + alto, y1), min(c0 + ancho, x1)
            mancha = rng.normal(0.45, 0.06, (r1 - r0, c1 - c0))
            fil, col = np.linspace(-1, 1, r1 - r0)[:, None], np.linspace(-1, 1, c1 - c0)[None, :]
            peso = np.clip(1.2 - (fil ** 2 + col ** 2), 0, 1)             # más intensa en el centro, se difumina al borde
            img[r0:r1, c0:c1] = peso * mancha + (1 - peso) * img[r0:r1, c0:c1]
        X[i, 0] = np.clip(img, 0, 1)
    return X, y

X, y = generar_fotos3(); print(X.shape, np.bincount(y))
np.set_printoptions(linewidth=120)
print("mojada:"); print((X[np.where(y == 2)[0][0], 0] * 9).astype(int))
Xtr, ytr, Xte, yte = X[:675], y[:675], X[675:], y[675:]
Xtr_t, ytr_t, Xte_t, yte_t = map(torch.tensor, (Xtr, ytr, Xte, yte))

def crear_cnn(n_clases=3):
    return nn.Sequential(nn.Conv2d(1, 8, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),      # 1x16x16 -> 8x8x8
                         nn.Conv2d(8, 16, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),     # -> 16x4x4
                         nn.Flatten(), nn.Linear(256, 32), nn.ReLU(), nn.Linear(32, n_clases))

def aumentar(xb, rng):
    """Espejo horizontal/vertical y rotación de 0/90/180/270° al azar, imagen a imagen."""
    out = xb.clone()
    for i in range(len(out)):
        if rng.random() < 0.5: out[i] = torch.flip(out[i], dims=[2])
        if rng.random() < 0.5: out[i] = torch.flip(out[i], dims=[1])
        k = int(rng.integers(0, 4))
        if k: out[i] = torch.rot90(out[i], k, dims=[1, 2])
    return out

def acierto(red, Xt, yt):
    red.eval()
    with torch.no_grad(): return (red(Xt).argmax(1) == yt).float().mean().item()

def entrenar(red, Xt, yt, epocas=30, tasa=0.003, lote=32, aumento=False, semilla=0, verbose=False):
    torch.manual_seed(semilla); rng = np.random.default_rng(semilla)
    opt, fn = torch.optim.Adam(red.parameters(), lr=tasa), nn.CrossEntropyLoss()
    for ep in range(1, epocas + 1):
        red.train(); perm = torch.randperm(len(Xt))
        for i in range(0, len(perm), lote):
            idx = perm[i:i + lote]; xb = aumentar(Xt[idx], rng) if aumento else Xt[idx]
            opt.zero_grad(); fn(red(xb), yt[idx]).backward(); opt.step()
        if verbose and ep in (1, 5, 10, 20, 30):
            print(f"  época {ep:2d}  acierto train {acierto(red, Xt, yt):.3f}  test {acierto(red, Xte_t, yte_t):.3f}")

t = time.time(); torch.manual_seed(0); cnn = crear_cnn(); print("parámetros:", sum(p.numel() for p in cnn.parameters()))
entrenar(cnn, Xtr_t, ytr_t, verbose=True); print(f"{time.time() - t:.1f} s")
with torch.no_grad(): pred = cnn(Xte_t).argmax(1).numpy()
print(confusion_matrix(yte, pred)); print("Acierto test:", round((pred == yte).mean(), 3))
print("Errores:", [(CLASES[a], "->", CLASES[b]) for a, b in zip(yte[pred != yte], pred[pred != yte])])
for n_tr, ep in ((150, 60), (675, 30)):
    for aum in (False, True):
        accs = []
        for s in range(4):
            torch.manual_seed(s); red = crear_cnn(); entrenar(red, Xtr_t[:n_tr], ytr_t[:n_tr], epocas=ep, aumento=aum, semilla=s); accs.append(acierto(red, Xte_t, yte_t))
        print(f"n_train={n_tr:3d} épocas={ep} aumento={aum!s:5s}: {np.round(accs, 3)} media {np.mean(accs):.3f}")
torch.manual_seed(0); mlp = nn.Sequential(nn.Flatten(), nn.Linear(256, 64), nn.ReLU(), nn.Linear(64, 3)); entrenar(mlp, Xtr_t, ytr_t)
print("MLP:", sum(p.numel() for p in mlp.parameters()), "parámetros, acierto test", round(acierto(mlp, Xte_t, yte_t), 3))
(900, 1, 16, 16) [298 293 309]
mojada:
[[1 0 1 1 1 0 1 1 1 1 1 0 0 0 1 1]
 [0 0 1 0 6 6 6 5 6 5 6 5 6 0 0 1]
 [0 0 1 0 5 5 6 5 2 4 6 6 6 0 1 1]
 [0 1 1 1 6 6 5 4 4 3 4 7 7 0 0 0]
 [0 0 1 0 6 5 6 4 4 4 6 6 5 0 0 1]
 [1 0 1 0 6 7 6 6 5 6 5 6 6 0 1 1]
 ...
parámetros: 9571
  época  1  acierto train 0.493  test 0.458
  época  5  acierto train 0.630  test 0.600
  época 10  acierto train 0.647  test 0.627
  época 20  acierto train 0.963  test 0.911
  época 30  acierto train 0.990  test 0.978
1.1 s
[[70  0  1]
 [ 1 74  3]
 [ 0  0 76]]
Acierto test: 0.978
Errores: [('danada', '->', 'mojada'), ('danada', '->', 'correcta'), ('danada', '->', 'mojada'), ('danada', '->', 'mojada'), ('correcta', '->', 'mojada')]
n_train=150 épocas=60 aumento=False: [0.924 0.862 0.907 0.867] media 0.890
n_train=150 épocas=60 aumento=True : [0.951 0.92  0.916 0.911] media 0.924
n_train=675 épocas=30 aumento=False: [0.978 0.978 0.951 0.982] media 0.972
n_train=675 épocas=30 aumento=True : [0.978 0.973 0.942 0.964] media 0.964
MLP: 16643 parámetros, acierto test 0.48

Lectura:

  • La tercera clase es más difícil que las dos de 05-04: la mancha (valores 3-4 sobre una caja de 6) es sutil, y la CNN pasa de 9.538 a 9.571 parámetros (33 más en la capa de salida) y tarda más en "arrancar" (época 10: 63 %; época 20: 91 %; época 30: 97,8 %). Casi todos los errores son dañada → mojada: una abolladura de 3×3 y una mancha pequeña se parecen. El MLP con 16.643 parámetros se queda en el 48 %, cerca del azar con tres clases (33 %): sin convolución no hay invariancia a la posición.
  • Aumento de datos: con solo 150 fotos, espejos y rotaciones suben el acierto medio del 89,0 % al 92,4 % y reducen la varianza entre semillas (0,86-0,92 → 0,91-0,95): la red ve cada foto en 8 orientaciones y no puede memorizar posiciones. Con 675 fotos no aporta (97,2 % frente a 96,4 %): la variedad natural ya cubre lo que el aumento inventa. Es la regla de 05-03: el aumento es una regularización, valiosa cuando los datos escasean.
  • Nota que las transformaciones deben preservar la etiqueta: una caja mojada girada sigue mojada; en cambio, en un problema de lectura de etiquetas o de flechas "este lado arriba", una rotación cambiaría el significado.

Retroalimentación

  • Error típico: aumentar también el conjunto de test (se evalúa siempre sobre imágenes originales) o aumentar una vez al principio en lugar de en cada época (se pierde la variedad). Otro: usar torch.flip sobre las dimensiones equivocadas (aquí cada imagen es (1, 16, 16): dims 1 y 2 son alto y ancho).
  • Con más épocas la CNN sin aumento acabaría memorizando; con parada temprana sobre una validación (proyecto 1) evitarías tener que elegir 30 a mano.
  • Variantes: añade una cuarta clase "etiqueta ilegible" (un rectángulo pequeño muy claro con ruido) y mira si la matriz de confusión se degrada; visualiza los 8 filtros de la primera capa (cnn[0].weight) y comprueba que alguno detecta bordes diagonales; entrena con n_tr=75 y observa hasta dónde llega el aumento.

  1. Proyecto 3: GRU para la demanda semanal frente a las líneas base y la lineal

Recordatorio (05-04, sección 5). Una red recurrente procesa una secuencia paso a paso manteniendo un estado oculto; las GRU/LSTM usan compuertas para recordar dependencias largas. nn.GRU(input_size=1, hidden_size=h, batch_first=True) recibe tensores (lote, pasos, 1) y devuelve los estados de todos los pasos y el último. En 09-02, la lineal de calendario tiene MAE 16,8 en las semanas 79-104 y la línea base ingenua unas 30-39 según se defina.

Enunciado. Pasos: (1) construye ventanas de 12 semanas → semana siguiente (92 ventanas; las 26 últimas, semanas 79-104, son el test); (2) representa cada ventana en relativo (restando su última semana, para que la red prediga el incremento y no dependa del nivel) y escala con una única desviación calculada solo con entrenamiento; (3) define RedGRU (GRU + capa lineal sobre el último estado) y entrenar_gru con Adam y MSELoss sobre el lote completo; (4) elige tamaño oculto y número de épocas con las 13 últimas ventanas del entrenamiento como validación (semanas 66-78); (5) reentrena con las 66 ventanas y evalúa en test con 5 semillas; (6) compara MAE con la línea base ingenua (última semana), la media de 4 semanas, una regresión lineal sobre la ventana (12 coeficientes) y la lineal de calendario de 04-05, desglosando el error de la semana 100 (Black Friday) y la 101.

Solución

from sklearn.linear_model import LinearRegression
from sklearn.metrics import mean_absolute_error
from novamarket_ml import generar_demanda_semanal

d = generar_demanda_semanal(104, 42); serie = d["unidades"].values.astype(np.float32)
VENTANA, N_TEST, N_VAL = 12, 26, 13
def ventanas(serie, ventana):
    return np.array([serie[t - ventana:t] for t in range(ventana, len(serie))]), serie[ventana:]
Xw, yw = ventanas(serie, VENTANA)                                  # 92 ventanas -> semanas objetivo 13..104
semana_obj = np.arange(VENTANA + 1, len(serie) + 1)
es_test = semana_obj > len(serie) - N_TEST                         # semanas 79-104
ultimo = Xw[:, -1]                                                 # última semana de cada ventana
Xr, yr = Xw - ultimo[:, None], yw - ultimo                          # relativo: la red predice el incremento
escala = Xr[~es_test].std()                                        # una sola escala, solo con entrenamiento
tensor = lambda a: torch.tensor(a / escala, dtype=torch.float32)
Xtr, ytr, Xte = tensor(Xr[~es_test]).unsqueeze(-1), tensor(yr[~es_test]).view(-1, 1), tensor(Xr[es_test]).unsqueeze(-1)
print("ventanas:", Xw.shape, "| entrenamiento:", tuple(Xtr.shape), "| test:", tuple(Xte.shape))

class RedGRU(nn.Module):
    def __init__(self, oculto=8):
        super().__init__()
        self.gru = nn.GRU(input_size=1, hidden_size=oculto, batch_first=True)
        self.salida = nn.Linear(oculto, 1)
    def forward(self, x):
        estados, ultimo_estado = self.gru(x)          # estados: (lote, 12, oculto); ultimo_estado: (1, lote, oculto)
        return self.salida(ultimo_estado[0])

def entrenar_gru(X_t, y_t, oculto=8, epocas=50, tasa=0.01, semilla=0):
    torch.manual_seed(semilla); red = RedGRU(oculto)
    opt, fn = torch.optim.Adam(red.parameters(), lr=tasa), nn.MSELoss()
    for _ in range(epocas):                            # lote completo: 66 ventanas caben de sobra
        red.train(); opt.zero_grad(); fn(red(X_t), y_t).backward(); opt.step()
    return red.eval()

def predecir(red, X_t, ultimo_semana):
    with torch.no_grad(): return red(X_t).numpy().ravel() * escala + ultimo_semana

Xa, ya, Xv = Xtr[:-N_VAL], ytr[:-N_VAL], Xtr[-N_VAL:]            # validación: semanas 66-78
y_val, ult_val = yw[~es_test][-N_VAL:], ultimo[~es_test][-N_VAL:]
resultados = {}
for oculto in (8, 16, 32):
    for epocas in (50, 100, 200, 400):
        maes = [mean_absolute_error(y_val, predecir(entrenar_gru(Xa, ya, oculto, epocas, semilla=s), Xv, ult_val)) for s in range(3)]
        resultados[(oculto, epocas)] = np.mean(maes)
print({k: round(float(v), 1) for k, v in resultados.items()}); print("Ingenua en validación:", round(mean_absolute_error(y_val, ult_val), 1))
mejor = min(resultados, key=resultados.get); print("Mejor configuración:", mejor)
maes = [mean_absolute_error(yw[es_test], predecir(entrenar_gru(Xtr, ytr, *mejor, semilla=s), Xte, ultimo[es_test])) for s in range(5)]
print(f"GRU test, 5 semillas: {np.round(maes, 1)} media {np.mean(maes):.1f}")
red = entrenar_gru(Xtr, ytr, *mejor, semilla=0); print("parámetros:", sum(p.numel() for p in red.parameters()))
d["sen"], d["cos"] = np.sin(2 * np.pi * d["semana"] / 52), np.cos(2 * np.pi * d["semana"] / 52)
d["black_friday"] = ((d["semana"] - 1) % 52 == 47).astype(int)
feats, tr, te = ["semana", "sen", "cos", "black_friday"], d["semana"] <= 78, d["semana"] > 78
comparar = {"GRU (semilla 0)": predecir(red, Xte, ultimo[es_test]),
            "Ingenua: última semana": Xw[es_test][:, -1], "Media de 4 semanas": Xw[es_test][:, -4:].mean(1),
            "Lineal sobre la ventana (12 coef.)": LinearRegression().fit(Xw[~es_test], yw[~es_test]).predict(Xw[es_test]),
            "Lineal de calendario (04-05)": LinearRegression().fit(d.loc[tr, feats], d.loc[tr, "unidades"]).predict(d.loc[te, feats])}
for nombre, p in comparar.items():
    err = pd.Series(np.abs(yw[es_test] - p), index=semana_obj[es_test])
    print(f"{nombre:36s} MAE {err.mean():5.1f} | sem. 100 (BF) {err[100]:5.1f} | sem. 101 {err[101]:5.1f} | resto {err.drop([100, 101]).mean():4.1f} | sesgo {np.mean(yw[es_test] - p):+.1f}")
ventanas: (92, 12) | entrenamiento: (66, 12, 1) | test: (26, 12, 1)
{(8, 50): 15.9, (8, 100): 16.7, (8, 200): 25.5, (8, 400): 32.2, (16, 50): 16.3, (16, 100): 20.6, (16, 200): 19.9, (16, 400): 28.7, (32, 50): 19.3, (32, 100): 19.3, (32, 200): 19.5, (32, 400): 19.2}
Ingenua en validación: 18.7
Mejor configuración: (8, 50)
GRU test, 5 semillas: [33.9 29.3 32.2 34.3 30.1] media 32.0
parámetros: 273
GRU (semilla 0)                      MAE  33.9 | sem. 100 (BF) 212.9 | sem. 101 118.7 | resto 22.9 | sesgo -12.7
Ingenua: última semana               MAE  34.3 | sem. 100 (BF) 215.0 | sem. 101 238.0 | resto 18.3 | sesgo -1.5
Media de 4 semanas                   MAE  31.4 | sem. 100 (BF) 241.5 | sem. 101  59.5 | resto 21.5 | sesgo -4.1
Lineal sobre la ventana (12 coef.)   MAE  29.8 | sem. 100 (BF) 231.8 | sem. 101  92.2 | resto 18.8 | sesgo -0.4
Lineal de calendario (04-05)         MAE  16.8 | sem. 100 (BF)  27.5 | sem. 101   8.6 | resto 16.7 | sesgo -7.0

Lectura:

  • En validación la GRU (15,9) parece batir a la ingenua (18,7), y las configuraciones largas empeoran (400 épocas: 32,2), señal de sobreajuste con 53 ventanas. Pero en test la GRU da 32,0 ± 2 (5 semillas), igual que la ingenua (34,3), la media de 4 (31,4) y la lineal sobre la ventana (29,8), y muy lejos de la lineal de calendario (16,8). Fuera de las semanas 100 y 101 la GRU (22,9) es incluso peor que la ingenua (18,3).
  • Por qué no gana: (1) tiene 66 ejemplos para 273 parámetros; (2) su ventana de 12 semanas es más corta que el periodo estacional (52), así que no puede ver la estacionalidad que la lineal recibe directamente con sen y cos; (3) nadie le dice que la semana 100 es Black Friday (error 213, como todas las que solo miran el pasado; y la 101 lo paga por tener el pico dentro de la ventana), mientras la lineal lo sabe por la variable black_friday; (4) el ruido de la serie es independiente de una semana a otra, así que no hay dinámica corta que aprender: lo mejor que puede hacer con la ventana es aproximar la persistencia. Sin la representación relativa era aún peor: con normalización absoluta la red no extrapola la tendencia y subestima sistemáticamente (sesgo +25 unidades).
  • Con 4 u 8 años de datos sintéticos (generar_demanda_semanal(208), (416)) la GRU sigue en 32-40 frente a 18-21 de la lineal: el problema no es solo el tamaño, es que la información que importa (calendario) no está en la ventana. Una recurrente compite cuando hay dependencias temporales ricas que el calendario no captura (rachas de promociones, roturas de stock, efectos de una campaña que se prolongan) y ventanas que cubren el ciclo relevante, o cuando se le dan también las variables de calendario como entradas exógenas.
  • Decisión: la lineal de calendario. Y una lección de método: la validación pequeña (13 semanas) engañó; con series cortas, la comparación con líneas base y con el modelo de referencia en el mismo test es imprescindible antes de creerse una mejora.

Retroalimentación

  • Error típico: normalizar con la media y desviación de toda la serie (fuga del futuro) o barajar las ventanas antes de separar el test (ventanas de test solapadas con entrenamiento). Otro: pasar a nn.GRU tensores (lote, 12) sin la dimensión de característica; hace falta unsqueeze(-1).
  • MSELoss sobre datos escalados es lo habitual; el MAE se calcula después de deshacer la escala y sumar la última semana.
  • Variantes: da a la GRU un segundo canal con black_friday de cada semana de la ventana y una tercera entrada con la de la semana objetivo (exógena conocida de antemano); prueba nn.LSTM; usa ventanas de 52 semanas con generar_demanda_semanal(416) (entonces sí puede ver el ciclo anual) y comprueba si se acerca a la lineal.

  1. Proyecto 4: sentimiento de reseñas con embeddings desde cero frente a la bolsa de palabras

Recordatorio (05-05, secciones 3-4). La bolsa de palabras (CountVectorizer) + logística obtiene 0,767 de exactitud en validación cruzada sobre 56 reseñas; los embeddings representan cada palabra como un vector denso aprendido; nn.EmbeddingBag(mode="mean") promedia los vectores de las palabras de una frase en una sola pasada y una capa lineal decide. Un modelo preentrenado (05-05, sección 5) trae esos vectores ya aprendidos de miles de millones de frases.

Enunciado. Pasos: (1) escribe generar_resenas_ampliado(n=400, semilla=42) que use las plantillas de 05-05 y devuelva también el identificador de la plantilla; (2) evalúa la bolsa de palabras + logística con StratifiedKFold y con GroupKFold por plantilla, y explica la diferencia; (3) escribe un tokenizador, un vocabulario y a_indices (índices concatenados + offsets, el formato de EmbeddingBag); (4) define RedEmb (EmbeddingBag de dimensión 8 + lineal) y entrénala 100 épocas con Adam y weight_decay=1e-3; evalúa con las mismas dos validaciones; (5) compara predicciones sobre frases nuevas y mira qué palabras han quedado más positivas y más negativas en el espacio aprendido; (6) discute cuándo compensaría un modelo preentrenado (sin ejecutarlo).

Solución

import re
from collections import Counter
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.model_selection import GroupKFold, StratifiedKFold, cross_val_score
from resenas_nm import POSITIVAS, NEGATIVAS, PRODUCTOS

def generar_resenas_ampliado(n=400, semilla=42):
    rng = np.random.default_rng(semilla); filas = []
    for i in range(n):
        sentimiento = i % 2; lista = POSITIVAS if sentimiento else NEGATIVAS
        k = int(rng.integers(len(lista))); p = rng.choice(PRODUCTOS)
        filas.append({"id_resena": f"R{1000 + i}", "texto": lista[k].format(p=p, P=p[0].upper() + p[1:]),
                      "sentimiento": sentimiento, "plantilla": f"{'P' if sentimiento else 'N'}{k:02d}"})
    return pd.DataFrame(filas).sample(frac=1, random_state=semilla).reset_index(drop=True)

res = generar_resenas_ampliado(); print(res.shape, "plantillas distintas:", res["plantilla"].nunique())
bow = Pipeline([("bolsa", CountVectorizer()), ("log", LogisticRegression(max_iter=1000))])
skf, gkf = StratifiedKFold(5, shuffle=True, random_state=42), GroupKFold(5)
print("BoW, CV estratificada:", cross_val_score(bow, res["texto"], res["sentimiento"], cv=skf).round(3))
sc = cross_val_score(bow, res["texto"], res["sentimiento"], cv=gkf, groups=res["plantilla"]); print("BoW, CV por plantilla:", sc.round(3), round(sc.mean(), 3))

def tokenizar(t): return re.findall(r"\w+", t.lower())
def construir_vocab(textos):
    vocab = {"<unk>": 0}
    for w in Counter(w for t in textos for w in tokenizar(t)): vocab[w] = len(vocab)
    return vocab
def a_indices(textos, vocab):
    idx, offsets = [], [0]
    for t in textos:
        toks = [vocab.get(w, 0) for w in tokenizar(t)]; idx += toks; offsets.append(offsets[-1] + len(toks))
    return torch.tensor(idx), torch.tensor(offsets[:-1])

class RedEmb(nn.Module):
    def __init__(self, n_vocab, dim=8):
        super().__init__(); self.emb = nn.EmbeddingBag(n_vocab, dim, mode="mean"); self.salida = nn.Linear(dim, 1)
    def forward(self, idx, off): return self.salida(self.emb(idx, off))

def entrenar_emb(textos, y, vocab, dim=8, epocas=100, tasa=0.02, wd=1e-3, semilla=0):
    torch.manual_seed(semilla); red = RedEmb(len(vocab), dim)
    opt, fn = torch.optim.Adam(red.parameters(), lr=tasa, weight_decay=wd), nn.BCEWithLogitsLoss()
    idx, off = a_indices(textos, vocab); y_t = torch.tensor(y, dtype=torch.float32).view(-1, 1)
    for _ in range(epocas):
        red.train(); opt.zero_grad(); fn(red(idx, off), y_t).backward(); opt.step()
    return red.eval()

def acierto_emb(red, textos, y, vocab):
    idx, off = a_indices(textos, vocab)
    with torch.no_grad(): return float(((red(idx, off) > 0).numpy().ravel().astype(int) == np.asarray(y)).mean())

for nombre, cv, grupos in (("estratificada", skf, None), ("por plantilla", gkf, res["plantilla"])):
    accs = []
    for tr, te in cv.split(res["texto"], res["sentimiento"], groups=grupos):
        vocab = construir_vocab(res["texto"].iloc[tr])                    # vocabulario solo con entrenamiento
        red = entrenar_emb(res["texto"].iloc[tr].tolist(), res["sentimiento"].iloc[tr].values, vocab)
        accs.append(acierto_emb(red, res["texto"].iloc[te].tolist(), res["sentimiento"].iloc[te].values, vocab))
    print(f"EmbeddingBag, CV {nombre}:", np.round(accs, 3), round(np.mean(accs), 3))

vocab = construir_vocab(res["texto"]); red = entrenar_emb(res["texto"].tolist(), res["sentimiento"].values, vocab)
print("vocabulario:", len(vocab), "| parámetros:", sum(p.numel() for p in red.parameters()))
nuevas = ["La cafetera es genial, muy contenta con la compra", "El televisor llegó roto y el soporte no responde",
          "El robot aspirador no es nada silencioso, un desastre", "No está nada mal, la freidora funciona bien y llegó rápido",
          "Esperaba que fuera malo pero los auriculares son excelentes", "La batería del portátil dura poquísimo, decepcionante"]
bow.fit(res["texto"], res["sentimiento"]); idx, off = a_indices(nuevas, vocab)
with torch.no_grad(): p_emb = torch.sigmoid(red(idx, off)).numpy().ravel()
for t, pb, pe in zip(nuevas, bow.predict_proba(nuevas)[:, 1], p_emb): print(f"BoW {pb:.2f}  Emb {pe:.2f}  {t}")
E, inv = red.emb.weight.detach(), {i: w for w, i in vocab.items()}
puntuacion = (E @ red.salida.weight[0].detach()).numpy(); orden = np.argsort(puntuacion)   # proyección de cada palabra sobre la dirección de salida
print("Más negativas:", [inv[i] for i in orden[:8]]); print("Más positivas:", [inv[i] for i in orden[-8:][::-1]])
(400, 4) plantillas distintas: 56
BoW, CV estratificada: [1. 1. 1. 1. 1.]
BoW, CV por plantilla: [0.85  0.638 0.675 0.8   0.725] 0.737
EmbeddingBag, CV estratificada: [1. 1. 1. 1. 1.] 1.0
EmbeddingBag, CV por plantilla: [0.888 0.588 0.75  0.738 0.7  ] 0.733
vocabulario: 267 | parámetros: 2145
BoW 0.95  Emb 0.99  La cafetera es genial, muy contenta con la compra
BoW 0.05  Emb 0.02  El televisor llegó roto y el soporte no responde
BoW 0.15  Emb 0.21  El robot aspirador no es nada silencioso, un desastre
BoW 0.31  Emb 0.21  No está nada mal, la freidora funciona bien y llegó rápido
BoW 0.54  Emb 0.69  Esperaba que fuera malo pero los auriculares son excelentes
BoW 0.19  Emb 0.28  La batería del portátil dura poquísimo, decepcionante
Más negativas: ['no', 'se', 'mala', 'vino', 'ruidoso', 'nada', 'abierto', 'caro']
Más positivas: ['que', 'genial', 'es', 'todo', 'precio', 'bien', 'buena', 'funciona']

Lectura:

  • La validación estratificada da el 100 % para los dos modelos, y es mentira: con 400 frases generadas a partir de 56 plantillas, cada plantilla aparece unas 7 veces (con productos distintos) y el modelo ve en test frases casi idénticas a las de entrenamiento. Es la fuga de 04-03 en versión texto, y por eso hay que validar por grupos (GroupKFold por plantilla): entonces la bolsa de palabras da 0,737 (0,64-0,85 según el pliegue) y los embeddings 0,733. "Tener n = 400" no ha aportado nada respecto a las 56 frases de 05-05 (0,767), porque la diversidad de redacciones sigue siendo 56.
  • Los embeddings desde cero empatan con la bolsa de palabras: 2.145 parámetros aprendidos de 56 formulaciones no pueden capturar más que la frecuencia de "no", "mala", "genial", que la bolsa ya cuenta. Las palabras más negativas y positivas del espacio aprendido son casi las mismas que los coeficientes de la logística en 05-05; y ambos fallan igual en las frases con negación o contraste ("No está nada mal", "Esperaba que fuera malo pero…"): promediar vectores o contar palabras ignora el orden.
  • Cuándo compensa un modelo preentrenado: cuando el problema exige entender frases nuevas que no se parecen a las de entrenamiento (sinónimos, ironía, negaciones, errores ortográficos), y no se tienen miles de ejemplos etiquetados de diversidad real. Un codificador preentrenado en español (por ejemplo, un BERT multilingüe o un modelo de sentence embeddings, 05-05) trae vectores en los que "decepcionante", "una estafa" y "no lo recomiendo" ya están cerca, y "genial", "un acierto" y "vale cada euro" también; con esos vectores fijos y una logística encima, 56 plantillas bastarían para superar el 0,9 en frases realmente nuevas. El precio: dependencia de un modelo de cientos de MB, más latencia, y la obligación de comprobar sesgos heredados (02-04). Con las reseñas reales de NovaMarket (miles, diversas), la primera opción hoy sería un modelo preentenado ajustado; el proyecto 09-04 lo deja como alternativa con guion.

Retroalimentación

  • Error típico: construir el vocabulario (o ajustar CountVectorizer) con todas las frases antes de la validación cruzada: es una fuga menor pero real (las palabras del test se cuelan en el vocabulario). Otro: olvidar <unk> y que a_indices reviente con una palabra nueva.
  • Si el EmbeddingBag no aprende, revisa offsets (deben ser las posiciones de inicio de cada frase, sin la última) y la tasa de aprendizaje (0,02 aquí, alta porque los gradientes de los embeddings son dispersos).
  • Variantes: usa mode="sum" y compara; añade bigramas al tokenizador ("no funciona" como una unidad) y mira si mejoran las frases con negación (la bolsa binaria con unigramas ya sube a 0,775 por plantilla); escribe 10 reseñas nuevas a mano, muy distintas de las plantillas, y úsalas como test final de los dos modelos.

Errores Comunes y Consejos

  • Elegir por test. En los cuatro proyectos hay un conjunto de validación (o validación cruzada por grupos) para elegir arquitectura, épocas y tasa; el test se mira una vez. Si eliges por test, tu cifra final es optimista y no lo sabes.
  • Una semilla no es un resultado. Las redes pequeñas sobre datos pequeños varían ±0,01 de AUC, ±3 puntos de acierto y ±3 unidades de MAE entre semillas. Repite 3-5 veces y da media y rango.
  • Fugas específicas de cada tipo de dato: escalar series con estadísticas de toda la serie; ventanas de test que solapan con entrenamiento; vocabulario ajustado con el test; plantillas repetidas entre pliegues; aumento de datos aplicado al test.
  • Comparar la red con una versión débil del clásico. La lineal con calendario, la logística con su pipeline y la bolsa de palabras bien validada son adversarios serios; batirlos con datos pequeños es la excepción, no la regla (05-03).
  • Olvidar el eval() y evaluar con dropout activo, o el zero_grad() y acumular gradientes; y con EmbeddingBag, confundir índices y offsets.
  • Consejo: cada proyecto termina con una tabla y una frase de decisión, como en 09-02: "MLP empata (0,840 frente a 0,842): logística"; "CNN 97,8 % con tres clases; aumento útil con pocas fotos"; "GRU 32 frente a lineal 16,8: lineal"; "embeddings 0,733 frente a BoW 0,737: BoW, y preentrenado si hay presupuesto".

Conclusión

Cuatro proyectos y una respuesta matizada a la pregunta de 05-03. El MLP de devoluciones, tras siete configuraciones y cinco semillas, empata con la logística (0,840 frente a 0,842): en tabular pequeño no compensa. La CNN de fotos absorbe una tercera clase con el 97,8 % de acierto (los errores son dañada → mojada) frente al 48 % de un MLP mayor, y el aumento por espejos y rotaciones vale cuando hay 150 fotos (89 → 92 %) y no cuando hay 675: en imágenes la red es la herramienta, y el aumento su regularización. La GRU sobre la demanda (32 de MAE, como las líneas base) pierde con la lineal de calendario (16,8) porque su ventana no ve el ciclo anual ni el Black Friday y no hay dinámica corta que aprender: las recurrentes necesitan dependencias temporales reales y datos. Y los embeddings desde cero empatan con la bolsa de palabras (0,733 frente a 0,737) una vez que la validación por plantilla desenmascara el 100 % ficticio; el salto vendría de un modelo preentrenado, no de más parámetros. En todos ellos, la misma disciplina que en 09-01 y 09-02: validación honesta, líneas base y una decisión escrita.

Queda el último paso del módulo: en 09-04, Proyecto Integrador, dejarás de resolver ejercicios sueltos y aplicarás el método completo de 08-01 a un caso de NovaMarket que aún no se ha cerrado de principio a fin: el diagnóstico de incidencias del caso 9, combinando la red bayesiana de 06-03 con un clasificador entrenado sobre incidencias sintéticas y las reglas de negocio de 06-04, desde el documento de definición hasta la model card y la presentación a Diego; y con el caso 4 (reseñas y prioridad de respuesta) como alternativa con guion.

Fundamentos de Inteligencia Artificial (IA)

Módulo 1: Introducción a la Inteligencia Artificial

Módulo 2: Principios Básicos de la IA

Módulo 3: Algoritmos en IA

Módulo 4: Aprendizaje Automático (Machine Learning)

Módulo 5: Redes Neuronales y Deep Learning

Módulo 6: Lógica y Sistemas Expertos

Módulo 7: Herramientas y Lenguajes de Programación en IA

Módulo 8: Proyectos y Casos de Estudio

Módulo 9: Ejercicios y Prácticas

Módulo 10: Recursos Adicionales

© Copyright 2026. Todos los derechos reservados