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
- Proyecto 1: MLP de devoluciones con búsqueda de arquitectura y tasa de aprendizaje
- Proyecto 2: CNN de fotos de incidencias con una tercera clase y aumento de datos
- Proyecto 3: GRU para la demanda semanal frente a las líneas base y la lineal
- Proyecto 4: sentimiento de reseñas con embeddings desde cero frente a la bolsa de palabras
- Errores Comunes y Consejos
- Conclusión
- 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.842Lectura 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 fijartorch.manual_seedantes 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-4en Adam (regularización L2) y compara con el dropout; pruebann.BatchNorm1dentre capas; mide el coste de negocio al umbral 0,2 (09-02) del mejor MLP frente a la logística; usaoptunaoRandomizedSearchCVconskorchsi quieres automatizar la búsqueda (fuera del alcance del curso).
- 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.48Lectura:
- 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.flipsobre 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 conn_tr=75y observa hasta dónde llega el aumento.
- 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.0Lectura:
- 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
senycos; (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 variableblack_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.GRUtensores(lote, 12)sin la dimensión de característica; hace faltaunsqueeze(-1). MSELosssobre 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_fridayde cada semana de la ventana y una tercera entrada con la de la semana objetivo (exógena conocida de antemano); pruebann.LSTM; usa ventanas de 52 semanas congenerar_demanda_semanal(416)(entonces sí puede ver el ciclo anual) y comprueba si se acerca a la lineal.
- 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 (
GroupKFoldpor 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 quea_indicesreviente con una palabra nueva. - Si el
EmbeddingBagno aprende, revisaoffsets(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 elzero_grad()y acumular gradientes; y conEmbeddingBag, 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
- Conceptos Fundamentales: Agentes, Entornos y Racionalidad
- Tipos de Inteligencia Artificial
- Los Datos como Materia Prima de la IA
- Ética y Consideraciones en IA
Módulo 3: Algoritmos en IA
- Introducción a los Algoritmos
- Algoritmos de Búsqueda
- Búsqueda con Adversario: Juegos y Minimax
- Algoritmos de Optimización
Módulo 4: Aprendizaje Automático (Machine Learning)
- Conceptos Básicos de Machine Learning
- Tipos de Aprendizaje Automático
- Preparación de Datos y Características
- Algoritmos de Machine Learning
- Evaluación y Validación de Modelos
- Sobreajuste, Regularización y Ajuste de Hiperparámetros
Módulo 5: Redes Neuronales y Deep Learning
- Introducción a las Redes Neuronales
- Arquitectura de Redes Neuronales
- Cómo Aprende una Red: Descenso del Gradiente y Retropropagación
- Deep Learning y sus Aplicaciones
- Transformers, Grandes Modelos de Lenguaje e IA Generativa
Módulo 6: Lógica y Sistemas Expertos
- Lógica en IA
- Sistemas Expertos
- Razonamiento con Incertidumbre: Probabilidad y Redes Bayesianas
- Aplicaciones de Sistemas Expertos
Módulo 7: Herramientas y Lenguajes de Programación en IA
- Lenguajes de Programación para IA
- Python Científico: NumPy, pandas y Matplotlib
- Herramientas y Librerías Populares
- Entornos de Desarrollo
Módulo 8: Proyectos y Casos de Estudio
Módulo 9: Ejercicios y Prácticas
- Ejercicios de Algoritmos
- Prácticas de Machine Learning
- Proyectos de Redes Neuronales
- Proyecto Integrador: de la Idea al Prototipo
