11  Regularización y generalización

Una red puede aprender muy bien las observaciones disponibles y perder esa ventaja cuando cambian los ejemplos. La pregunta del capítulo es qué intervención tiene sentido ante esa evidencia y cómo comprobar su efecto sin confundir ajuste, selección y evaluación final.

Estudiaremos capacidad, L1, L2, dropout, BatchNorm y parada temprana sobre las mismas particiones de Fashion-MNIST. Las redes se implementan con PyTorch y conservan el patrón de los capítulos de MLP y optimización. La búsqueda general de hiperparámetros se desarrolla en el capítulo 8; aquí interesa su relación con la regularización.

11.1 Qué vas a poder decidir

  • Distinguir un ajuste insuficiente de una divergencia entre entrenamiento y validación.
  • Leer el gap junto con el nivel absoluto de las métricas, el presupuesto y el modo de evaluación.
  • Comparar intervenciones controlando arquitectura, inicialización y orden de los lotes.
  • Separar entropía cruzada, penalización y objetivo de entrenamiento.
  • Elegir un checkpoint y un procedimiento con validación antes de consultar prueba.
  • Explicar cuándo una mejora promedio puede ocultar un deterioro por clase.

Usaremos una secuencia de lectura: describir el resultado, proponer una hipótesis compatible y decidir un contraste. Una curva puede apoyar una hipótesis sin identificar de forma única su causa.

11.2 Entorno y presupuesto experimental

Los tamaños y presupuestos se fijan antes de entrenar. Las semillas reducen fuentes concretas de azar, sin garantizar igualdad entre dispositivos o versiones. Una sola semilla no cuantifica toda la variabilidad de cada técnica.

from pathlib import Path
from copy import deepcopy
import random, json, time, platform
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.datasets import load_digits, make_moons
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import (accuracy_score, f1_score, classification_report,
    confusion_matrix, ConfusionMatrixDisplay, roc_curve, roc_auc_score)
import sklearn
import torch
from torch import nn
from torch.utils.data import DataLoader, TensorDataset
from IPython.display import display, Markdown

def semilla(valor=42):
    random.seed(valor); np.random.seed(valor); torch.manual_seed(valor)
    if torch.cuda.is_available(): torch.cuda.manual_seed_all(valor)

semilla()
torch.set_num_threads(2)
dispositivo = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
ARTEFACTOS = Path('artefactos_pytorch'); ARTEFACTOS.mkdir(exist_ok=True)
plt.rcParams.update({'figure.dpi':115,'font.size':10,'axes.spines.top':False,'axes.spines.right':False})
versiones = {'python':platform.python_version(),'torch':str(torch.__version__),
             'numpy':np.__version__,'sklearn':sklearn.__version__}
print(versiones, '| dispositivo:',dispositivo)

N_TRAIN,N_VAL,N_TEST=4000,1500,1500
LOTE=128; EPOCHS_FULL=30; EPOCHS_COMPARE=30; EPOCHS_EARLY=60
PATIENCE=8; LR=.001
NOMBRES=['Camiseta','Pantalón','Jersey','Vestido','Abrigo','Sandalia','Camisa','Zapatilla','Bolso','Botín']
print('Fashion-MNIST: ajuste / validación / prueba:',N_TRAIN,N_VAL,N_TEST)
print('Techos: comparación',EPOCHS_COMPARE,'| parada temprana',EPOCHS_EARLY)
{'python': '3.13.9', 'torch': '2.14.1+cpu', 'numpy': '2.3.5', 'sklearn': '1.8.0'} | dispositivo: cpu
Fashion-MNIST: ajuste / validación / prueba: 4000 1500 1500
Techos: comparación 30 | parada temprana 60

11.3 Generalizar: qué medimos y qué no observamos

Si las observaciones provienen de \(p(x,y)\), el riesgo esperado es

\[R(\theta)=\mathbb E_{(x,y)\sim p}\,[\ell(y,f_\theta(x))].\]

No conocemos esa distribución completa. Ajustamos con una muestra y estimamos comportamiento fuera del ajuste con otras observaciones. Un rendimiento de entrenamiento alto no basta: el modelo puede aprovechar regularidades transferibles y particularidades de la muestra en proporciones que no se distinguen mirando solo ese conjunto.

Entrenamiento actualiza pesos; validación elige época y configuración; prueba evalúa decisiones fijadas. Si consultamos prueba repetidamente para cambiar el modelo, deja de ser una evaluación independiente del procedimiento diseñado. En este capítulo los barridos muestran solo desarrollo; la evaluación de prueba aparece al final.

11.3.1 Dos gaps con sentidos distintos

\[g_{acc}=acc_{train}-acc_{val},\qquad g_{loss}=CE_{val}-CE_{train}.\]

Un gap positivo suele describir ventaja de entrenamiento, pero debe leerse junto al nivel absoluto. Dos exactitudes cercanas a 0.10 pueden dar un gap pequeño en un problema de diez clases sin que el predictor sea útil. Tampoco existe un valor universal de gap que diagnostique sobreajuste.

ejemplos=pd.DataFrame({'escenario':['Ajuste bajo','Aciertos próximos','Divergencia ilustrativa'],
    'train_acc':[.55,.84,.99],'val_acc':[.54,.82,.78],
    'train_CE':[1.2,.48,.04],'val_CE':[1.22,.53,.94]})
ejemplos['gap_acc']=ejemplos.train_acc-ejemplos.val_acc
ejemplos['gap_CE']=ejemplos.val_CE-ejemplos.train_CE
print('Cifras hipotéticas: no son resultados de Fashion-MNIST.')
display(ejemplos.round(3))
Cifras hipotéticas: no son resultados de Fashion-MNIST.
escenario train_acc val_acc train_CE val_CE gap_acc gap_CE
0 Ajuste bajo 0.55 0.54 1.20 1.22 0.01 0.02
1 Aciertos próximos 0.84 0.82 0.48 0.53 0.02 0.05
2 Divergencia ilustrativa 0.99 0.78 0.04 0.94 0.21 0.90

11.4 Ajuste insuficiente, divergencia y capacidad

Si entrenamiento y validación permanecen en un nivel bajo, hay varias hipótesis: representación insuficiente, pocos pasos, tasa inadecuada o regularización excesiva. Añadir más penalización sin distinguirlas puede empeorar el problema.

Si la pérdida de entrenamiento sigue bajando mientras validación deja de mejorar o empeora, el patrón es compatible con sobreajuste. No hace falta que entrenamiento alcance exactitud uno. La evolución importa: una fotografía del gap al final puede ocultar una buena época anterior.

11.4.1 Sesgo y varianza no son nombres alternativos de esas curvas

En regresión con error cuadrático y ruido de media condicional cero, la descomposición clásica en un punto es

\[\mathbb E[(y-\hat f(x))^2]=(\mathbb E[\hat f(x)]-f(x))^2+ \mathbb E[(\hat f(x)-\mathbb E[\hat f(x)])^2]+\operatorname{Var}(y\mid x).\]

La expectativa sobre el estimador considera muestras de entrenamiento distintas. Con un solo split no estimamos esa varianza. Esta expresión tampoco es una descomposición directa de la entropía cruzada de nuestras redes multiclase.

La figura siguiente es conceptual. Su forma de U organiza una intuición clásica; no garantiza que todo experimento de redes siga esa forma, ni identifica el ancho correcto de una capa.

# Gráfica CONCEPTUAL de complejidad vs error (no es un resultado empírico)
complejidad = np.linspace(0.15, 1.0, 200)
bias2 = 0.55 * (1.15 - complejidad) ** 2 + 0.04
variance = 0.62 * (complejidad - 0.15) ** 2 + 0.03
irreducible = np.full_like(complejidad, 0.06)
error_total = bias2 + variance + irreducible
i_opt = int(np.argmin(error_total))

fig, ax = plt.subplots(figsize=(9.5, 4.8))
ax.plot(complejidad, bias2, label=r"sesgo$^{2}$", lw=2)
ax.plot(complejidad, variance, label="varianza", lw=2)
ax.plot(complejidad, error_total, label="error esperado (suma)", lw=2.4, color="black")
ax.axhline(0.06, ls="--", color="gray", lw=1, label="error irreducible (esquema)")
ax.axvline(complejidad[i_opt], color="crimson", ls=":", lw=1.4,
           label="mínimo conceptual del error")
ax.set_xlabel("Complejidad / capacidad del modelo (eje abstracto)")
ax.set_ylabel("Error (unidades arbitrarias)")
ax.set_title("Esquema clásico del compromiso sesgo–varianza\n(no es un experimento de este notebook)")
ax.legend(loc="upper center")
ax.set_xticks([])
ax.set_yticks([])
ax.text(0.04, 0.88, "zona de\nunderfitting\n(alto sesgo)", fontsize=9, transform=ax.transAxes, bbox=dict(facecolor="white", alpha=.85, edgecolor="none"))
ax.text(0.80, 0.88, "zona de\noverfitting\n(alta varianza)", fontsize=9, transform=ax.transAxes, bbox=dict(facecolor="white", alpha=.85, edgecolor="none"))
plt.tight_layout()
plt.show()

print(
    "Esta figura NO estima el sesgo ni la varianza de nuestros modelos. "
    "No identifica cuántas neuronas son 'las correctas' en Fashion-MNIST. "
    "Solo organiza la idea de que aumentar capacidad empuja el error de "
    "un régimen (sesgo) hacia otro (varianza)."
)

Esta figura NO estima el sesgo ni la varianza de nuestros modelos. No identifica cuántas neuronas son 'las correctas' en Fashion-MNIST. Solo organiza la idea de que aumentar capacidad empuja el error de un régimen (sesgo) hacia otro (varianza).

11.5 Laboratorio geométrico: tres capacidades sobre medias lunas

Antes de trabajar con prendas, observaremos fronteras en dos dimensiones. Se mantienen 250 medias lunas con ruido 0.35 y semilla 42. El 35% reservado actúa aquí como validación, no como prueba final: permite discutir decisiones de capacidad. Las arquitecturas son (4,), (16,8) y (128,128,64).

Fijamos Adam 0.001, 2500 épocas de lote completo y conservamos el mejor estado de validación. No imponemos etiquetas de «bueno» o «sobreajustado» a cada tamaño antes de ejecutarlo. El escalador se estima con entrenamiento.

11.6 Un protocolo común: entrenar, medir y restaurar

El patrón continúa el piloto de perceptrón y MLP: DataLoader para lotes, train() al ajustar y eval() con gradientes desactivados al medir. Las salidas son logits y CrossEntropyLoss recibe etiquetas enteras. No añadimos softmax antes de esa pérdida.

Cada época registra dos lecturas de entrenamiento: online_* se obtiene con los pesos cambiando durante los lotes y con dropout activo si existe; train_* se mide al terminar, con el estado fijo en modo evaluación. Esta última se compara con validación. Se pondera cada lote por su tamaño.

La pérdida predictiva es entropía cruzada media. El objetivo de ajuste puede sumar L1/L2, calculadas como sumas sobre las matrices ocultas y excluyendo sesgos, normalización y salida. Registramos ambos términos: comparar pérdidas con penalizaciones distintas confundiría calidad predictiva y definición del objetivo. Aquí no sustituimos esa penalización por weight_decay de Adam ni por AdamW.

Todas las redes conservan el estado de menor entropía cruzada de validación, incluidos los buffers de BatchNorm. El techo de épocas es presupuesto, no el checkpoint. La paciencia puede detener antes; min_delta controla esa espera sin cambiar qué mínimo exacto se conserva. Si aparece una pérdida o norma no finita, se declara el fallo numérico y se detiene la corrida: no se interpreta como un modelo válido.

class Red(nn.Module):
    def __init__(self, entradas, ocultas, salidas, *, dropout=0., bn=False,
                 inicializacion='xavier', semilla_pesos=42):
        super().__init__()
        self.config = dict(entradas=entradas, ocultas=list(ocultas), salidas=salidas,
            dropout=dropout,bn=bn,inicializacion=inicializacion,semilla_pesos=semilla_pesos)
        capas=[]; anterior=entradas
        for ancho in ocultas:
            capas.append(nn.Linear(anterior,ancho,bias=not bn))
            if bn: capas.append(nn.BatchNorm1d(ancho,eps=1e-3,momentum=.01))
            capas.append(nn.ReLU())
            if dropout>0: capas.append(nn.Dropout(dropout))
            anterior=ancho
        capas.append(nn.Linear(anterior,salidas))
        self.capas=nn.Sequential(*capas)
        # Reiniciar aquí hace coincidir matrices comunes entre intervenciones.
        semilla(semilla_pesos)
        for capa in self.capas:
            if isinstance(capa,nn.Linear):
                if inicializacion=='cero': nn.init.zeros_(capa.weight)
                elif inicializacion=='grande': nn.init.normal_(capa.weight,0,1)
                elif inicializacion=='he': nn.init.kaiming_normal_(capa.weight,nonlinearity='relu')
                else: nn.init.xavier_uniform_(capa.weight,gain=1.)
                if capa.bias is not None: nn.init.zeros_(capa.bias)

    def forward(self,x): return self.capas(x)

    def penalizacion(self,l1=0.,l2=0.):
        # Penalizamos matrices ocultas; excluimos sesgos, BN y la capa de salida.
        densas=[c for c in self.capas if isinstance(c,nn.Linear)][:-1]
        valor=torch.zeros((),device=next(self.parameters()).device)
        for c in densas: valor=valor+l1*c.weight.abs().sum()+l2*c.weight.square().sum()
        return valor

def cargador(X,y,lote=128,barajar=False,seed=42):
    return DataLoader(TensorDataset(torch.as_tensor(np.asarray(X),dtype=torch.float32),
        torch.as_tensor(np.asarray(y),dtype=torch.long)),batch_size=lote,shuffle=barajar,
        generator=torch.Generator().manual_seed(seed),num_workers=0)

@torch.inference_mode()
def evaluar(red,X,y):
    red.eval(); suma=0.; aciertos=0; n=0
    for xb,yb in cargador(X,y,512):
        xb,yb=xb.to(dispositivo),yb.to(dispositivo)
        z=red(xb); loss=nn.functional.cross_entropy(z,yb)
        if not torch.isfinite(z).all() or not torch.isfinite(loss):
            return {'loss':np.nan,'accuracy':np.nan}
        suma+=loss.item()*len(yb); aciertos+=(z.argmax(1)==yb).sum().item(); n+=len(yb)
    return {'loss':suma/n,'accuracy':aciertos/n}

@torch.inference_mode()
def probabilidades(red,X):
    red.eval(); out=[]
    for start in range(0,len(X),512):
        xb=torch.as_tensor(np.asarray(X[start:start+512]),dtype=torch.float32,device=dispositivo)
        out.append(red(xb).softmax(1).cpu().numpy())
    return np.concatenate(out)

def optimizador(red,nombre,lr):
    if nombre=='SGD': return torch.optim.SGD(red.parameters(),lr=lr)
    if nombre=='Momentum': return torch.optim.SGD(red.parameters(),lr=lr,momentum=.9)
    if nombre=='RMSProp': return torch.optim.RMSprop(red.parameters(),lr=lr,alpha=.9,eps=1e-7)
    return torch.optim.Adam(red.parameters(),lr=lr,eps=1e-7)

def ajustar(red,Xtr,ytr,Xva,yva,*,epocas=30,lote=128,lr=.001,opt='Adam',
            l1=0.,l2=0.,paciencia=None,min_delta=0.,clip=None,seed=42,nombre='modelo'):
    red.to(dispositivo); algoritmo=optimizador(red,opt,lr)
    lotes=cargador(Xtr,ytr,lote,True,seed)
    registros=[]; mejor=np.inf; mejor_epoca=0; espera=0; referencia_parada=np.inf
    estado_mejor=deepcopy(red.state_dict()); estado='finito'; total_pasos=0
    inicio=time.perf_counter()
    if dispositivo.type=='cuda': torch.cuda.synchronize()
    for epoca in range(1,epocas+1):
        red.train(); suma_online=0.; correctos_online=0; n_online=0
        normas=[]; recortes=0; fallo=False
        for xb,yb in lotes:
            xb,yb=xb.to(dispositivo),yb.to(dispositivo)
            algoritmo.zero_grad(set_to_none=True)
            z=red(xb); ce=nn.functional.cross_entropy(z,yb)
            objetivo=ce+red.penalizacion(l1,l2)
            if not torch.isfinite(objetivo): fallo=True; break
            objetivo.backward()
            # Sin clip, umbral infinito mide sin modificar gradientes finitos.
            norma=nn.utils.clip_grad_norm_(red.parameters(),clip if clip else float('inf'))
            if not torch.isfinite(norma): fallo=True; break
            normas.append(float(norma)); recortes+=int(clip is not None and norma>clip)
            suma_online+=float(ce.detach())*len(yb)
            correctos_online+=(z.argmax(1)==yb).sum().item(); n_online+=len(yb)
            algoritmo.step(); total_pasos+=1
        tr=evaluar(red,Xtr,ytr); va=evaluar(red,Xva,yva)
        penal=float(red.penalizacion(l1,l2).detach())
        if fallo or not np.isfinite([tr['loss'],va['loss'],penal]).all(): estado='no_finito'
        registros.append({'epoca':epoca,'train_loss':tr['loss'],'val_loss':va['loss'],
            'train_accuracy':tr['accuracy'],'val_accuracy':va['accuracy'],
            'online_loss':suma_online/max(n_online,1),'online_accuracy':correctos_online/max(n_online,1),
            'penalizacion':penal,'objetivo_train':tr['loss']+penal,
            'norma_gradiente_media':float(np.mean(normas)) if normas else np.nan,
            'fraccion_recortada':recortes/max(len(normas),1)})
        if estado=='no_finito': break
        if va['loss']<mejor:
            mejor=va['loss']; mejor_epoca=epoca; estado_mejor=deepcopy(red.state_dict())
        # El mínimo exacto elige pesos; min_delta solo controla la paciencia.
        if va['loss']<referencia_parada-min_delta: referencia_parada=va['loss']; espera=0
        else: espera+=1
        if paciencia is not None and espera>=paciencia: break
    if dispositivo.type=='cuda': torch.cuda.synchronize()
    segundos=time.perf_counter()-inicio
    red.load_state_dict(estado_mejor); red.eval()
    if mejor_epoca:
        assert np.isclose(evaluar(red,Xva,yva)['loss'],mejor,rtol=1e-5,atol=1e-7)
    info={'modelo':nombre,'checkpoint':mejor_epoca,'epocas':len(registros),'estado':estado,
          'segundos':segundos,'pasos':total_pasos,'min_val_loss':mejor}
    print(nombre, '| épocas:',len(registros),'| checkpoint:',mejor_epoca,'| estado:',estado,flush=True)
    return pd.DataFrame(registros),info

def curvas(historia,info,titulo):
    fig,ax=plt.subplots(1,2,figsize=(11,3.5))
    for pref,nombre in [('train','Entrenamiento · eval'),('val','Validación')]:
        ax[0].plot(historia.epoca,historia[pref+'_loss'],label=nombre)
        ax[1].plot(historia.epoca,historia[pref+'_accuracy'],label=nombre)
    for a in ax:
        if info['checkpoint']: a.axvline(info['checkpoint'],ls='--',color='black',alpha=.5,label='Checkpoint')
        a.set_xlabel('Época'); a.legend(fontsize=8)
    ax[0].set(ylabel='Entropía cruzada sin penalización',title=titulo+' · pérdida')
    ax[1].set(ylabel='Exactitud',ylim=(0,1.03),title=titulo+' · aciertos')
    plt.tight_layout(); plt.show()

def resumen(red,h,info,Xtr,ytr,Xva,yva):
    tr=evaluar(red,Xtr,ytr); va=evaluar(red,Xva,yva)
    return {**info,'parametros':sum(p.numel() for p in red.parameters()),
        'train_loss':tr['loss'],'val_loss':va['loss'],'train_accuracy':tr['accuracy'],
        'val_accuracy':va['accuracy'],'gap_accuracy':tr['accuracy']-va['accuracy'],
        'gap_loss':va['loss']-tr['loss']}

def guardar(red,nombre,contrato):
    ruta=ARTEFACTOS/(nombre+'.pt')
    torch.save({'config':red.config,'state_dict':{k:v.detach().cpu().clone() for k,v in red.state_dict().items()},
        'entrada':contrato},ruta)
    nuevo=torch.load(ruta,map_location='cpu',weights_only=True)
    copia=Red(**nuevo['config']); copia.load_state_dict(nuevo['state_dict']); copia.eval()
    return copia
X_m,y_m=make_moons(n_samples=250,noise=.35,random_state=42)
tr_m,va_m=train_test_split(np.arange(len(y_m)),test_size=.35,stratify=y_m,random_state=42)
escala=StandardScaler().fit(X_m[tr_m]); Xmtr=escala.transform(X_m[tr_m]); Xmva=escala.transform(X_m[va_m])
xx,yy=np.meshgrid(np.linspace(X_m[:,0].min()-.4,X_m[:,0].max()+.4,180),
                  np.linspace(X_m[:,1].min()-.4,X_m[:,1].max()+.4,180))
rejilla=escala.transform(np.c_[xx.ravel(),yy.ravel()])
fig,axes=plt.subplots(1,3,figsize=(13,3.7)); filas_m=[]
for ax,(nombre,ocultas) in zip(axes,[('Pequeña',[4]),('Intermedia',[16,8]),('Amplia',[128,128,64])]):
    red=Red(2,ocultas,2)
    h,info=ajustar(red,Xmtr,y_m[tr_m],Xmva,y_m[va_m],epocas=2500,lote=len(tr_m),nombre='Lunas '+nombre)
    fila=resumen(red,h,info,Xmtr,y_m[tr_m],Xmva,y_m[va_m]);filas_m.append(fila)
    p=probabilidades(red,rejilla)[:,1].reshape(xx.shape)
    ax.contourf(xx,yy,p,levels=np.linspace(0,1,11),cmap='RdBu_r',alpha=.6)
    ax.contour(xx,yy,p,levels=[.5],colors='black',linewidths=.8)
    ax.scatter(*X_m[tr_m].T,c=y_m[tr_m],cmap='coolwarm',s=18,edgecolors='black',linewidths=.3)
    ax.scatter(*X_m[va_m].T,c=y_m[va_m],cmap='coolwarm',marker='^',s=35,edgecolors='black',label='Validación')
    ax.set(title=f'{nombre}\ntrain={fila["train_accuracy"]:.2f} · val={fila["val_accuracy"]:.2f}',xlabel='x1',ylabel='x2')
    ax.legend(fontsize=8)
plt.tight_layout();plt.show()
display(pd.DataFrame(filas_m)[['modelo','checkpoint','train_accuracy','val_accuracy','gap_accuracy']].round(3))
Lunas Pequeña | épocas: 2500 | checkpoint: 2500 | estado: finito
Lunas Intermedia | épocas: 2500 | checkpoint: 618 | estado: finito
Lunas Amplia | épocas: 2500 | checkpoint: 78 | estado: finito

modelo checkpoint train_accuracy val_accuracy gap_accuracy
0 Lunas Pequeña 2500 0.809 0.875 -0.066
1 Lunas Intermedia 618 0.895 0.886 0.009
2 Lunas Amplia 78 0.883 0.909 -0.026

11.6.1 Cómo leer las fronteras

Una región fragmentada alrededor de puntos aislados es una señal para investigar sensibilidad al ruido, no una prueba suficiente de sobreajuste. Mira también la pérdida de validación y la época elegida. Una red amplia puede producir un checkpoint sencillo si se detiene antes de perseguir particularidades.

El gráfico presenta los tres modelos en sus estados seleccionados, no necesariamente en la última época. No concluyas que el ancho carece de efecto porque sus porcentajes se parezcan: pueden diferir estabilidad, frontera y sensibilidad a otra muestra.

11.7 Fashion-MNIST: datos, escala y particiones

Fashion-MNIST contiene 60 000 imágenes oficiales de entrenamiento y 10 000 de prueba, de 28×28 píxeles y diez clases de prendas. Usaremos 4000 imágenes de ajuste y 1500 de validación extraídas de las primeras, más 1500 de la prueba oficial. Los dos sorteos estratificados del desarrollo y el sorteo de prueba usan semilla 42, preservando las particiones del experimento de referencia.

Una MLP ancha sobre un subconjunto permite observar diferencias con un coste manejable; sus resultados absolutos no se comparan con publicaciones que entrenan con el conjunto completo. Aplanamos a 784 entradas y dividimos por 255, una constante de la representación de píxeles.

El lector de archivos IDX utiliza NumPy y gzip. Reutiliza archivos locales si están disponibles y descarga desde el repositorio oficial solo si faltan. Una caché previa puede estar en una carpeta llamada .keras; leer esos archivos no requiere TensorFlow ni Keras.

import gzip,struct,hashlib,urllib.request
def archivo_fashion(nombre):
    candidatos=[Path('datos-fashion')/nombre,
        Path.home()/'.cache/bit-datasets/fashion-mnist'/nombre,
        Path.home()/'.keras/datasets/fashion-mnist'/nombre]
    for p in candidatos:
        if p.exists(): return p
    destino=Path('datos-fashion')/nombre;destino.parent.mkdir(exist_ok=True)
    url='https://raw.githubusercontent.com/zalandoresearch/fashion-mnist/master/data/fashion/'+nombre
    with urllib.request.urlopen(url,timeout=90) as response:
        contenido=response.read()
    gzip.decompress(contenido)  # Comprueba integridad gzip antes de conservarlo.
    destino.write_bytes(contenido);return destino

hashes_datos={}
def leer_idx(nombre,imagenes):
    p=archivo_fashion(nombre);raw=p.read_bytes();hashes_datos[nombre]=hashlib.sha256(raw).hexdigest()
    contenido=gzip.decompress(raw)
    if imagenes:
        magic,n,alto,ancho=struct.unpack('>IIII',contenido[:16]);assert magic==2051 and (alto,ancho)==(28,28)
        return np.frombuffer(contenido,dtype=np.uint8,offset=16).reshape(n,alto,ancho)
    magic,n=struct.unpack('>II',contenido[:8]);assert magic==2049
    return np.frombuffer(contenido,dtype=np.uint8,offset=8).reshape(n)

img_train=leer_idx('train-images-idx3-ubyte.gz',True)
label_train=leer_idx('train-labels-idx1-ubyte.gz',False)
img_test=leer_idx('t10k-images-idx3-ubyte.gz',True)
label_test=leer_idx('t10k-labels-idx1-ubyte.gz',False)
assert len(img_train)==60000 and len(img_test)==10000
pool,_=train_test_split(np.arange(len(label_train)),train_size=N_TRAIN+N_VAL,
    stratify=label_train,random_state=42)
itr,iva=train_test_split(pool,train_size=N_TRAIN,stratify=label_train[pool],random_state=42)
ite,_=train_test_split(np.arange(len(label_test)),train_size=N_TEST,stratify=label_test,random_state=42)
assert not set(itr)&set(iva)
Xtr=img_train[itr].reshape(-1,784).astype('float32')/255.;ytr=label_train[itr].astype('int64')
Xva=img_train[iva].reshape(-1,784).astype('float32')/255.;yva=label_train[iva].astype('int64')
# Prueba se prepara una vez; no se consulta en los barridos de desarrollo.
Xte=img_test[ite].reshape(-1,784).astype('float32')/255.;yte=label_test[ite].astype('int64')
particiones=pd.concat([pd.DataFrame({'fuente':'train_oficial','indice':itr,'particion':'train'}),
    pd.DataFrame({'fuente':'train_oficial','indice':iva,'particion':'val'}),
    pd.DataFrame({'fuente':'test_oficial','indice':ite,'particion':'test'})],ignore_index=True)
assert not particiones[['fuente','indice']].duplicated().any()
particiones.to_csv(ARTEFACTOS/'particiones.csv',index=False)
print('Formas:',Xtr.shape,Xva.shape,Xte.shape,'| escala de ajuste:',Xtr.min(),Xtr.max())
display(pd.DataFrame({'Ajuste':np.bincount(ytr,minlength=10),
    'Validación':np.bincount(yva,minlength=10),'Prueba':np.bincount(yte,minlength=10)},index=NOMBRES))
fig,ax=plt.subplots(2,5,figsize=(10,4))
for k,a in enumerate(ax.ravel()):
    a.imshow(Xtr[np.flatnonzero(ytr==k)[0]].reshape(28,28),cmap='gray',vmin=0,vmax=1)
    a.set_title(f'{k}: {NOMBRES[k]}');a.axis('off')
plt.tight_layout();plt.show()
Formas: (4000, 784) (1500, 784) (1500, 784) | escala de ajuste: 0.0 1.0
Ajuste Validación Prueba
Camiseta 400 150 150
Pantalón 400 150 150
Jersey 400 150 150
Vestido 400 150 150
Abrigo 400 150 150
Sandalia 400 150 150
Camisa 400 150 150
Zapatilla 400 150 150
Bolso 400 150 150
Botín 400 150 150

11.8 Tres capacidades sobre las mismas prendas

Los modelos A (8,), B (64,32) y C (256,256,128) permiten comparar capacidad bajo presupuestos declarados: 12, 25 y 30 épocas, respectivamente. C será el baseline para las intervenciones. No atribuiremos todas sus diferencias solo al tamaño, porque los presupuestos A/B también son distintos.

Para las comparaciones principales se conserva Adam 0.001, lote 128, Xavier uniforme con ganancia 1, sesgos cero y el mismo orden de lotes por semilla. Todas las redes terminan restaurando su mínimo de validación; el baseline no es silenciosamente la última época. Los historiales conservan la trayectoria completa para estudiar qué habría ocurrido después de ese mínimo.

MODELOS={}; HISTORIAS={}; INFORMES={}; FILAS=[]
def experimento(nombre,ocultas=(256,256,128),*,epocas=30,l1=0.,l2=0.,dropout=0.,bn=False,
                lr=LR,paciencia=None,min_delta=0.):
    red=Red(784,list(ocultas),10,dropout=dropout,bn=bn)
    h,info=ajustar(red,Xtr,ytr,Xva,yva,epocas=epocas,lote=LOTE,lr=lr,l1=l1,l2=l2,
        paciencia=paciencia,min_delta=min_delta,nombre=nombre)
    assert info['estado']=='finito',nombre
    fila=resumen(red,h,info,Xtr,ytr,Xva,yva)
    fila.update(l1=l1,l2=l2,dropout=dropout,bn=bn,lr=lr)
    MODELOS[nombre]=red;HISTORIAS[nombre]=h;INFORMES[nombre]=info;FILAS.append(fila)
    h.to_csv(ARTEFACTOS/(nombre+'-historia.csv'),index=False)
    return fila

fila_A=experimento('A_estrecha',[8],epocas=12)
fila_B=experimento('B_intermedia',[64,32],epocas=25)
fila_base=experimento('Baseline',epocas=EPOCHS_FULL)
for nombre in ['A_estrecha','B_intermedia','Baseline']:
    curvas(HISTORIAS[nombre],INFORMES[nombre],nombre)
display(pd.DataFrame([fila_A,fila_B,fila_base])[
    ['modelo','parametros','checkpoint','train_accuracy','val_accuracy','gap_accuracy']].round(3))
A_estrecha | épocas: 12 | checkpoint: 12 | estado: finito
B_intermedia | épocas: 25 | checkpoint: 25 | estado: finito
Baseline | épocas: 30 | checkpoint: 10 | estado: finito

modelo parametros checkpoint train_accuracy val_accuracy gap_accuracy
0 A_estrecha 6370 12 0.796 0.777 0.019
1 B_intermedia 52650 25 0.918 0.837 0.080
2 Baseline 300938 10 0.906 0.833 0.074

11.8.1 Leer capacidad sin imponer un resultado

Compara primero la pérdida de entrenamiento: ¿A consigue reducirla suficientemente? Si no lo hace, capacidad pequeña es una hipótesis, pero también lo son presupuesto y optimización. Para C, localiza el mínimo de validación y observa si entrenamiento continúa mejorando después. Esa separación apoya una intervención sobre el procedimiento; no prueba que cada peso grande sea un patrón memorizado.

La línea vertical identifica el estado conservado. Las métricas de la tabla se calculan de nuevo en ese estado y en modo evaluación, no se copian de la última fila del historial. Una diferencia de un punto porcentual sobre 1500 imágenes exige cautela: no repetimos todavía todo con varias semillas o muestras.

11.9 L1: penalizar magnitudes y comprobar qué cambió

El objetivo es \(J=CE+\lambda_1\sum|w|\), sobre las matrices ocultas. Mantendremos el baseline y añadiremos solamente \(\lambda_1=10^{-4}\). La fuerza depende de usar suma o media, de los parámetros incluidos y del tamaño de la arquitectura; no se traslada el coeficiente sin esas convenciones.

L1 favorece magnitudes pequeñas. Adam con una penalización diferenciada por autograd no es un algoritmo proximal: no promete ceros exactos ni selección perfecta de variables. Compararemos cuantiles y proporción de pesos próximos a cero, además de validación.

fila_l1=experimento('L1',l1=1e-4,epocas=EPOCHS_COMPARE)
curvas(HISTORIAS['L1'],INFORMES['L1'],'L1 · penalización 1e-4')
def pesos_ocultos(red):
    densas=[x for x in red.capas if isinstance(x,nn.Linear)][:-1]
    return np.concatenate([x.weight.detach().cpu().numpy().ravel() for x in densas])
filas_pesos=[];fig,ax=plt.subplots(figsize=(8,3.8))
for nombre in ['Baseline','L1']:
    w=pesos_ocultos(MODELOS[nombre]);a=np.abs(w)
    filas_pesos.append({'modelo':nombre,'mediana_abs':np.median(a),'p95_abs':np.quantile(a,.95),
        'fraccion_abs_menor_1e-3':np.mean(a<1e-3),'norma_L1':np.sum(a),'norma_L2':np.linalg.norm(w)})
    ax.hist(w,bins=100,density=True,histtype='step',label=nombre)
ax.set(title='Matrices ocultas del checkpoint',xlabel='Peso',ylabel='Densidad');ax.legend()
plt.tight_layout();plt.show();display(pd.DataFrame(filas_pesos).round(4))
L1 | épocas: 30 | checkpoint: 22 | estado: finito

modelo mediana_abs p95_abs fraccion_abs_menor_1e-3 norma_L1 norma_L2
0 Baseline 0.0438 0.1043 0.0118 14127.044922 30.957300
1 L1 0.0019 0.0770 0.4312 5036.076172 17.461599

11.9.1 Cómo leer L1

Si aumenta la concentración cerca de cero, la intervención tuvo el efecto esperado sobre esas magnitudes. Eso no garantiza mejor generalización. Contrasta con las métricas: una caída simultánea de entrenamiento y validación puede indicar una penalización demasiado fuerte para este presupuesto.

Una variable puede seguir influyendo mediante muchos caminos aunque un peso particular se aproxime a cero. No interpretamos el histograma como explicación causal de los píxeles importantes.

11.10 L2: controlar magnitudes sin cambiar el modelo de salida

El objetivo suma \(\lambda_2\sum w^2\) con \(\lambda_2=10^{-3}\), manteniendo las mismas matrices penalizadas que en L1. Su gradiente añade \(2\lambda_2w\) a cada una. Se implementa explícitamente en el objetivo, con Adam sin weight decay. Así no confundimos L2 con la contracción desacoplada de AdamW.

La validación usa entropía cruzada sin penalización. Dos objetivos con distinto \(\lambda\) no comparten escala; una suma de pérdida y penalización menor no basta para comparar calidad predictiva.

fila_l2=experimento('L2',l2=1e-3,epocas=EPOCHS_COMPARE)
curvas(HISTORIAS['L2'],INFORMES['L2'],'L2 · penalización 1e-3')
fig,ax=plt.subplots(figsize=(8,3.5));h=HISTORIAS['L2']
ax.plot(h.epoca,h.train_loss,label='CE de entrenamiento · eval')
ax.plot(h.epoca,h.penalizacion,label='Penalización L2')
ax.plot(h.epoca,h.objetivo_train,label='Suma CE + penalización')
ax.set(xlabel='Época',ylabel='Valor',title='Separar ajuste predictivo y término de regularización');ax.legend()
plt.tight_layout();plt.show()
display(pd.DataFrame([fila_base,fila_l1,fila_l2])[
    ['modelo','train_loss','val_loss','train_accuracy','val_accuracy','gap_accuracy']].round(3))
L2 | épocas: 30 | checkpoint: 24 | estado: finito

modelo train_loss val_loss train_accuracy val_accuracy gap_accuracy
0 Baseline 0.273 0.480 0.906 0.833 0.074
1 L1 0.235 0.462 0.920 0.843 0.077
2 L2 0.213 0.465 0.926 0.845 0.082

11.11 Barrido de L2: poca y mucha regularización son hipótesis

Contrastamos \(\lambda\in\{0,10^{-5},10^{-3},10^{-1}\}\), con treinta épocas por configuración. Reutilizamos el baseline como \(\lambda=0\) y la corrida L2 anterior como \(10^{-3}\): no repetimos el mismo control con otro azar ni otro presupuesto.

El criterio prefijado es menor entropía cruzada de validación. La exactitud acompaña la lectura, pero no sustituiremos el criterio si otra fila parece más conveniente. Un mínimo interior es posible; no forzaremos que aparezca una curva en U en cuatro puntos.

nombres_lambda={0.:'Baseline',1e-5:'L2_1e-5',1e-3:'L2',.1:'L2_0.1'}
experimento('L2_1e-5',l2=1e-5,epocas=EPOCHS_COMPARE)
experimento('L2_0.1',l2=.1,epocas=EPOCHS_COMPARE)
tabla_lambda=pd.DataFrame([f for f in FILAS if f['modelo'] in nombres_lambda.values()]).sort_values('l2')
display(tabla_lambda[['modelo','l2','val_loss','train_accuracy','val_accuracy','gap_accuracy']].round(4))
fig,ax=plt.subplots(1,2,figsize=(11,3.6))
for lam,nombre in nombres_lambda.items():
    h=HISTORIAS[nombre];ax[0].plot(h.epoca,h.val_loss,label=f'λ={lam:g}')
ax[0].set(xlabel='Época',ylabel='CE de validación',title='Trayectorias con igual presupuesto');ax[0].legend()
ax[1].plot(tabla_lambda.l2,tabla_lambda.train_accuracy,'o-',label='Entrenamiento · eval')
ax[1].plot(tabla_lambda.l2,tabla_lambda.val_accuracy,'s-',label='Validación')
ax[1].set(xlabel='λ L2',ylabel='Exactitud',title='Nivel absoluto y brecha');ax[1].set_xscale('symlog',linthresh=1e-6);ax[1].set_xticks([0,1e-5,1e-3,.1],labels=['0','1e-5','1e-3','0.1']);ax[1].legend()
plt.tight_layout();plt.show()
mejor_lambda=tabla_lambda.loc[tabla_lambda.val_loss.idxmin()]
display(Markdown(f'### Lectura de la ejecución\n\nEl menor CE de validación del barrido corresponde a '
    f'**λ={mejor_lambda.l2:g}**, con **{mejor_lambda.val_loss:.3f}**. '
    'El ganador depende del conjunto, presupuesto y convención de penalización. Un gap menor con exactitud baja no se presenta como éxito de regularización.'))
L2_1e-5 | épocas: 30 | checkpoint: 16 | estado: finito
L2_0.1 | épocas: 30 | checkpoint: 29 | estado: finito
modelo l2 val_loss train_accuracy val_accuracy gap_accuracy
0 Baseline 0.000 0.4798 0.9065 0.8327 0.0738
2 L2_1e-5 0.000 0.4838 0.9320 0.8493 0.0827
1 L2 0.001 0.4649 0.9262 0.8447 0.0816
3 L2_0.1 0.100 0.8983 0.7285 0.7240 0.0045

11.11.1 Lectura de la ejecución

El menor CE de validación del barrido corresponde a λ=0.001, con 0.465. El ganador depende del conjunto, presupuesto y convención de penalización. Un gap menor con exactitud baja no se presenta como éxito de regularización.

11.12 Dropout: una red estocástica al ajustar y fija al evaluar

Con probabilidad \(r\) se anula una activación durante entrenamiento. La versión invertida escala las que sobreviven por \(1/(1-r)\); en eval() dropout no elimina unidades ni vuelve a aplicar esa corrección. No se eliminan parámetros del modelo ni unidades que permanezcan siempre desactivadas.

Compararemos tasas 0, 0.2, 0.5 y 0.7 después de cada ReLU. La tasa cero reutiliza el baseline. Los historiales online_accuracy incluyen dropout y pesos cambiantes; train_accuracy se mide al terminar la época sin dropout. La diferencia entre ambos no es por sí misma una medida de generalización.

nombres_dropout={0.:'Baseline',.2:'Dropout_0.2',.5:'Dropout_0.5',.7:'Dropout_0.7'}
for tasa,nombre in nombres_dropout.items():
    if tasa:experimento(nombre,dropout=tasa,epocas=EPOCHS_COMPARE)
tabla_dropout=pd.DataFrame([f for f in FILAS if f['modelo'] in nombres_dropout.values()]).sort_values('dropout')
display(tabla_dropout[['modelo','dropout','train_accuracy','val_accuracy','val_loss','gap_accuracy']].round(3))
fig,axes=plt.subplots(2,2,figsize=(11,7))
for ax,(tasa,nombre) in zip(axes.ravel(),nombres_dropout.items()):
    h=HISTORIAS[nombre]
    ax.plot(h.epoca,h.online_accuracy,label='Durante ajuste · train()')
    ax.plot(h.epoca,h.train_accuracy,label='Al final · eval()')
    ax.plot(h.epoca,h.val_accuracy,label='Validación · eval()')
    ax.set(xlabel='Época',ylabel='Exactitud',title=f'Dropout r={tasa}',ylim=(0,1.03));ax.legend(fontsize=8)
plt.tight_layout();plt.show()
mejor_dropout=tabla_dropout.loc[tabla_dropout.val_loss.idxmin()]
display(Markdown(f'### Lectura de la ejecución\n\nLa tasa con menor CE de validación es '
    f'**{mejor_dropout.dropout:g}**, con **{mejor_dropout.val_loss:.3f}**. '
    'Una tasa alta puede reducir el ajuste efectivo; si ocurre, observa si también baja validación. Si gana r=0, el experimento no respalda añadir dropout bajo estas condiciones.'))
Dropout_0.2 | épocas: 30 | checkpoint: 15 | estado: finito
Dropout_0.5 | épocas: 30 | checkpoint: 29 | estado: finito
Dropout_0.7 | épocas: 30 | checkpoint: 30 | estado: finito
modelo dropout train_accuracy val_accuracy val_loss gap_accuracy
0 Baseline 0.0 0.906 0.833 0.480 0.074
1 Dropout_0.2 0.2 0.910 0.839 0.459 0.071
2 Dropout_0.5 0.5 0.884 0.841 0.460 0.044
3 Dropout_0.7 0.7 0.754 0.737 0.637 0.017

11.12.1 Lectura de la ejecución

La tasa con menor CE de validación es 0.2, con 0.459. Una tasa alta puede reducir el ajuste efectivo; si ocurre, observa si también baja validación. Si gana r=0, el experimento no respalda añadir dropout bajo estas condiciones.

11.12.2 Una comprobación del modo de ejecución

Una pasada estocástica no permite atribuir toda la diferencia al mecanismo de dropout: los pesos también iban cambiando durante los lotes. Para aislar la operación, fijaremos una misma entrada y un módulo dropout sin otros componentes: en entrenamiento cambia la máscara y en evaluación se conserva la entrada.

semilla(42);entrada=torch.ones(2000)
drop=nn.Dropout(.5);drop.train();a=drop(entrada);b=drop(entrada)
drop.eval();salida_eval=drop(entrada)
assert not torch.equal(a,b) and torch.equal(salida_eval,entrada)
print('Fracción anulada en una máscara:',float((a==0).float().mean()))
print('Media con corrección invertida:',float(a.mean()),'| eval conserva la entrada:',torch.equal(salida_eval,entrada))
Fracción anulada en una máscara: 0.4959999918937683
Media con corrección invertida: 1.0080000162124634 | eval conserva la entrada: True

11.13 BatchNorm: comparar dinámica y estado de inferencia

Usamos la misma arquitectura oculta, con Linear sin sesgo → BatchNorm → ReLU. El control es el baseline sin normalización. Fijamos epsilon 0.001 y peso de nueva estadística 0.01 en la media móvil de PyTorch. Los pesos densos comunes parten de las mismas matrices; BN incorpora escala y desplazamiento entrenables, además de buffers que no son parámetros.

No describimos BN como una penalización L1/L2. Puede modificar estabilidad, velocidad de optimización y comportamiento predictivo; no garantiza reducir la varianza estadística entre conjuntos de entrenamiento. Para medir esa última harían falta otras muestras.

Al restaurar el checkpoint se recuperan también medias y varianzas móviles. Guardar las matrices de una época y los buffers de otra no conservaría el predictor elegido.

fila_bn=experimento('BatchNorm',bn=True,epocas=EPOCHS_COMPARE)
curvas(HISTORIAS['BatchNorm'],INFORMES['BatchNorm'],'BatchNorm')
display(pd.DataFrame([fila_base,fila_bn])[
    ['modelo','parametros','checkpoint','val_loss','train_accuracy','val_accuracy','segundos']].round(3))
print('Buffers de BN:',[nombre for nombre,_ in MODELOS['BatchNorm'].named_buffers()][:6])
BatchNorm | épocas: 30 | checkpoint: 9 | estado: finito

modelo parametros checkpoint val_loss train_accuracy val_accuracy segundos
0 Baseline 300938 10 0.480 0.906 0.833 31.391
1 BatchNorm 301578 9 0.489 0.967 0.831 14.647
Buffers de BN: ['capas.1.running_mean', 'capas.1.running_var', 'capas.1.num_batches_tracked', 'capas.4.running_mean', 'capas.4.running_var', 'capas.4.num_batches_tracked']

11.13.1 Cómo leer el resultado de BN

Compara pérdidas y aciertos del checkpoint, no solo cuándo cruza una curva un umbral relativo a su propio inicio. Ese umbral cambia si cada modelo comienza con otra pérdida. Los segundos incluyen entrenamiento y evaluación del protocolo en esta máquina; no se extrapolan a otro hardware ni a un servicio de inferencia.

Si normalizar mejora rapidez pero no validación final, el resultado sigue siendo informativo. Si empeora, revisa tamaño de lote, estadísticas móviles e interacción con tasa antes de atribuirlo a falta de capacidad.

11.14 Parada temprana: tres momentos diferentes

Entrenamos la arquitectura del baseline con techo de 60 épocas, paciencia 8 y min_delta=1e-4. Distinguimos el máximo permitido, la época en que se detuvo el ajuste y la época cuyo estado se conserva. Aquí incluso los controles de presupuesto fijo restauran su mínimo: la comparación estudia presupuesto y regla de parada, no «última época frente a checkpoint» de manera encubierta.

Una mejora menor que min_delta puede actualizar el mínimo exacto y aun así no reiniciar la paciencia. Por eso registramos esos dos criterios por separado. La elección sigue basándose en entropía cruzada, no en la época con mayor exactitud.

fila_es=experimento('EarlyStopping',epocas=EPOCHS_EARLY,paciencia=PATIENCE,min_delta=1e-4)
curvas(HISTORIAS['EarlyStopping'],INFORMES['EarlyStopping'],'Parada temprana')
display(pd.DataFrame([fila_base,fila_es])[
    ['modelo','epocas','checkpoint','val_loss','val_accuracy','gap_accuracy','segundos']].round(3))
display(Markdown(f'### Lectura de la ejecución\n\nEl techo era **{EPOCHS_EARLY}**, '
    f'el ajuste terminó en **{fila_es["epocas"]}** y se conservó la época **{fila_es["checkpoint"]}**. '
    f'Se evitaron {EPOCHS_EARLY-fila_es["epocas"]} épocas respecto al techo. '
    'Eso no equivale automáticamente al mismo porcentaje de ahorro temporal: las mediciones incluyen evaluaciones y tienen variabilidad.'))
EarlyStopping | épocas: 18 | checkpoint: 10 | estado: finito

modelo epocas checkpoint val_loss val_accuracy gap_accuracy segundos
0 Baseline 30 10 0.48 0.833 0.074 31.391
1 EarlyStopping 18 10 0.48 0.833 0.074 6.004

11.14.1 Lectura de la ejecución

El techo era 60, el ajuste terminó en 18 y se conservó la época 10. Se evitaron 42 épocas respecto al techo. Eso no equivale automáticamente al mismo porcentaje de ahorro temporal: las mediciones incluyen evaluaciones y tienen variabilidad.

11.15 Búsqueda pequeña: regularización y capacidad interactúan

El capítulo de hiperparámetros desarrolla el método general. Aquí sorteamos ocho configuraciones distintas con semilla 42, usando anchos 64/128/256, tasas 0.0005/0.001/0.003, dropout 0/0.2/0.4 y L2 0/0.0001/0.001. Cada red tiene dos capas ocultas: el ancho sorteado y la mitad, con mínimo de 32.

Fijamos doce épocas por configuración. Es un presupuesto menor que el de los controles de treinta: lo compararemos como otro procedimiento, sin atribuir todas las diferencias a un hiperparámetro individual. Seleccionamos por CE de validación del checkpoint. Prueba no aparece en la tabla de búsqueda.

rng=np.random.default_rng(42);vistos=set();configs=[]
while len(configs)<8:
    cfg=(int(rng.choice([64,128,256])),float(rng.choice([.0005,.001,.003])),
         float(rng.choice([0.,.2,.4])),float(rng.choice([0.,1e-4,1e-3])))
    if cfg not in vistos: vistos.add(cfg);configs.append(cfg)
for i,(ancho,lr,drop,lam) in enumerate(configs,1):
    experimento(f'Busqueda_{i}',[ancho,max(ancho//2,32)],epocas=12,lr=lr,dropout=drop,l2=lam)
tabla_busqueda=pd.DataFrame([f for f in FILAS if f['modelo'].startswith('Busqueda_')]).sort_values('val_loss')
display(tabla_busqueda[['modelo','parametros','lr','dropout','l2','checkpoint','val_loss','val_accuracy']].round(4))
elegido_busqueda=tabla_busqueda.iloc[0]['modelo']
print('Configuración de búsqueda elegida solo con validación:',elegido_busqueda)
Busqueda_1 | épocas: 12 | checkpoint: 12 | estado: finito
Busqueda_2 | épocas: 12 | checkpoint: 8 | estado: finito
Busqueda_3 | épocas: 12 | checkpoint: 12 | estado: finito
Busqueda_4 | épocas: 12 | checkpoint: 12 | estado: finito
Busqueda_5 | épocas: 12 | checkpoint: 12 | estado: finito
Busqueda_6 | épocas: 12 | checkpoint: 10 | estado: finito
Busqueda_7 | épocas: 12 | checkpoint: 12 | estado: finito
Busqueda_8 | épocas: 12 | checkpoint: 11 | estado: finito
modelo parametros lr dropout l2 checkpoint val_loss val_accuracy
7 Busqueda_8 109386 0.0010 0.2 0.0000 11 0.4853 0.8307
0 Busqueda_1 52650 0.0030 0.2 0.0001 12 0.4885 0.8247
6 Busqueda_7 235146 0.0010 0.2 0.0010 12 0.4887 0.8247
1 Busqueda_2 109386 0.0030 0.0 0.0010 8 0.4957 0.8340
3 Busqueda_4 235146 0.0030 0.4 0.0010 12 0.5023 0.8300
5 Busqueda_6 109386 0.0010 0.0 0.0010 10 0.5123 0.8273
4 Busqueda_5 109386 0.0005 0.4 0.0001 12 0.5663 0.7987
2 Busqueda_3 52650 0.0005 0.2 0.0010 12 0.5894 0.8060
Configuración de búsqueda elegida solo con validación: Busqueda_8

11.16 Combinar técnicas es una hipótesis adicional

Probamos la arquitectura ancha con L2 0.0001, dropout 0.3, BatchNorm y parada temprana de paciencia 8, con techo 60. Cambian varias operaciones a la vez: una mejora o deterioro no identifica cuál de ellas fue la causa. Para aislar interacciones haría falta una ablación factorial u otro diseño explícito.

Se conserva el mismo criterio de selección. No daremos por ganador al combinado por contener más mecanismos.

fila_comb=experimento('Combinado',l2=1e-4,dropout=.3,bn=True,epocas=60,paciencia=8,min_delta=1e-4)
curvas(HISTORIAS['Combinado'],INFORMES['Combinado'],'L2 + dropout + BN + parada temprana')
tabla_desarrollo=pd.DataFrame(FILAS).sort_values('val_loss',kind='stable').reset_index(drop=True)
display(tabla_desarrollo[['modelo','parametros','epocas','checkpoint','train_accuracy',
    'val_accuracy','val_loss','gap_accuracy','segundos']].round(3))
ganador=str(tabla_desarrollo.iloc[0]['modelo'])
print('Procedimiento elegido antes de consultar prueba:',ganador)
fig,axes=plt.subplots(1,2,figsize=(12,7))
orden=tabla_desarrollo.sort_values('val_loss',ascending=False)
axes[0].barh(orden.modelo,orden.val_loss);axes[0].set(xlabel='CE de validación',title='Criterio de selección')
axes[1].barh(orden.modelo,orden.gap_accuracy);axes[1].axvline(0,color='black',lw=.7)
axes[1].set(xlabel='Exactitud train − validación',title='Brecha: leer junto al nivel absoluto')
plt.tight_layout();plt.show()
tabla_desarrollo.to_csv(ARTEFACTOS/'resultados-desarrollo.csv',index=False)
Combinado | épocas: 16 | checkpoint: 8 | estado: finito

modelo parametros epocas checkpoint train_accuracy val_accuracy val_loss gap_accuracy segundos
0 Dropout_0.2 300938 30 15 0.910 0.839 0.459 0.071 39.202
1 Dropout_0.5 300938 30 29 0.884 0.841 0.460 0.044 42.370
2 L1 300938 30 22 0.920 0.843 0.462 0.077 23.251
3 L2 300938 30 24 0.926 0.845 0.465 0.082 26.606
4 Baseline 300938 30 10 0.906 0.833 0.480 0.074 31.391
5 EarlyStopping 300938 18 10 0.906 0.833 0.480 0.074 6.004
6 Combinado 301578 16 8 0.880 0.829 0.483 0.052 7.237
7 B_intermedia 52650 25 25 0.918 0.837 0.483 0.080 5.300
8 L2_1e-5 300938 30 16 0.932 0.849 0.484 0.083 33.939
9 Busqueda_8 109386 12 11 0.868 0.831 0.485 0.037 3.032
10 Busqueda_1 52650 12 12 0.870 0.825 0.488 0.045 2.247
11 Busqueda_7 235146 12 12 0.882 0.825 0.489 0.057 3.789
12 BatchNorm 301578 30 9 0.967 0.831 0.489 0.136 14.647
13 Busqueda_2 109386 12 8 0.874 0.834 0.496 0.040 2.277
14 Busqueda_4 235146 12 12 0.860 0.830 0.502 0.031 3.210
15 Busqueda_6 109386 12 10 0.877 0.827 0.512 0.049 2.484
16 Busqueda_5 109386 12 12 0.818 0.799 0.566 0.019 2.482
17 Busqueda_3 52650 12 12 0.826 0.806 0.589 0.020 2.128
18 Dropout_0.7 300938 30 30 0.754 0.737 0.637 0.017 41.517
19 A_estrecha 6370 12 12 0.796 0.777 0.664 0.019 2.277
20 L2_0.1 300938 30 29 0.728 0.724 0.898 0.005 36.400
Procedimiento elegido antes de consultar prueba: Dropout_0.2

11.16.1 Qué mirar en la tabla consolidada

Primero identifica el criterio: menor pérdida de validación. Después contrasta exactitud, gap, parámetros y coste. Un pequeño gap no autoriza elegir una red de bajo desempeño; una diferencia mínima entre dos configuraciones no establece una superioridad reproducible con una semilla.

La búsqueda y el combinado tienen presupuestos distintos del baseline. La tabla compara procedimientos completos; para una comparación de eficiencia rigurosa habría que fijar tiempo, actualizaciones o ejemplos vistos y repetir mediciones. Tampoco podemos descomponer los efectos del combinado leyendo una sola fila.

11.17 Evaluación final: congelar antes de abrir prueba

Evaluamos tres procedimientos prefijados: baseline como testigo, ganador de validación y combinado como contraste de diseño. Si alguno coincide, se evalúa una sola vez. Mostrar estos diagnósticos no autoriza sustituir el ganador por quien tenga mejor prueba.

La matriz de confusión complementa la exactitud. Las filas son clases verdaderas y las columnas predicciones. El recall de una clase es su diagonal dividida por el total de esa fila; la precisión divide por el total de la columna correspondiente.

evaluados=list(dict.fromkeys(['Baseline',ganador,'Combinado']))
resultados_prueba=[]; probas_prueba={}; informes_clase={}
for nombre in evaluados:
    p=probabilidades(MODELOS[nombre],Xte);pred=p.argmax(1)
    assert np.allclose(p.sum(1),1.,atol=1e-6)
    probas_prueba[nombre]=p
    resultados_prueba.append({'modelo':nombre,'elegido_en_validacion':nombre==ganador,
        'exactitud':accuracy_score(yte,pred),'f1_macro':f1_score(yte,pred,average='macro'),
        'CE':float(-np.log(np.maximum(p[np.arange(len(yte)),yte],1e-30)).mean())})
    reporte=pd.DataFrame(classification_report(yte,pred,labels=np.arange(10),
        target_names=NOMBRES,output_dict=True,zero_division=0)).T
    informes_clase[nombre]=reporte
    display(Markdown('### '+nombre));display(reporte.round(3))
    reporte.to_csv(ARTEFACTOS/(nombre+'-reporte-prueba.csv'))
    cm=confusion_matrix(yte,pred,labels=np.arange(10));normal=cm/cm.sum(1,keepdims=True)
    fig,axes=plt.subplots(1,2,figsize=(13,5.3))
    for ax,mat,titulo,formato in zip(axes,[cm,normal],['Conteos','Fracción por clase verdadera'],['d','.2f']):
        ConfusionMatrixDisplay(mat,display_labels=NOMBRES).plot(ax=ax,cmap='Blues',
            colorbar=False,values_format=formato,xticks_rotation=60)
        ax.set(xlabel='Clase predicha',ylabel='Clase verdadera',title=nombre+' · '+titulo)
    plt.tight_layout();plt.show()
tabla_prueba=pd.DataFrame(resultados_prueba);display(tabla_prueba.round(4))
tabla_prueba.to_csv(ARTEFACTOS/'evaluacion-final.csv',index=False)
fila_ganadora=tabla_prueba.loc[tabla_prueba.modelo==ganador].iloc[0]
display(Markdown(f'### Lectura de la ejecución\n\nEl procedimiento elegido con validación fue **{ganador}**. '
    f'En {len(yte)} prendas de prueba obtuvo exactitud **{fila_ganadora.exactitud:.1%}**, '
    f'F1 macro **{fila_ganadora.f1_macro:.3f}** y CE **{fila_ganadora.CE:.3f}**. '
    'Las otras filas son contrastes declarados, sin una nueva selección basada en prueba.'))

11.17.1 Baseline

precision recall f1-score support
Camiseta 0.743 0.827 0.782 150.000
Pantalón 0.986 0.927 0.955 150.000
Jersey 0.740 0.647 0.690 150.000
Vestido 0.917 0.740 0.819 150.000
Abrigo 0.623 0.847 0.718 150.000
Sandalia 0.932 0.920 0.926 150.000
Camisa 0.640 0.580 0.608 150.000
Zapatilla 0.885 0.920 0.902 150.000
Bolso 0.953 0.940 0.946 150.000
Botín 0.932 0.920 0.926 150.000
accuracy 0.827 0.827 0.827 0.827
macro avg 0.835 0.827 0.827 1500.000
weighted avg 0.835 0.827 0.827 1500.000

11.17.2 Dropout_0.2

precision recall f1-score support
Camiseta 0.835 0.773 0.803 150.000
Pantalón 1.000 0.927 0.962 150.000
Jersey 0.761 0.700 0.729 150.000
Vestido 0.817 0.893 0.854 150.000
Abrigo 0.688 0.733 0.710 150.000
Sandalia 0.970 0.873 0.919 150.000
Camisa 0.596 0.660 0.627 150.000
Zapatilla 0.872 0.907 0.889 150.000
Bolso 0.959 0.933 0.946 150.000
Botín 0.885 0.927 0.906 150.000
accuracy 0.833 0.833 0.833 0.833
macro avg 0.838 0.833 0.834 1500.000
weighted avg 0.838 0.833 0.834 1500.000

11.17.3 Combinado

precision recall f1-score support
Camiseta 0.664 0.933 0.776 150.000
Pantalón 1.000 0.933 0.966 150.000
Jersey 0.706 0.720 0.713 150.000
Vestido 0.850 0.833 0.842 150.000
Abrigo 0.807 0.640 0.714 150.000
Sandalia 0.923 0.873 0.897 150.000
Camisa 0.634 0.567 0.599 150.000
Zapatilla 0.873 0.913 0.893 150.000
Bolso 0.972 0.927 0.949 150.000
Botín 0.903 0.927 0.914 150.000
accuracy 0.827 0.827 0.827 0.827
macro avg 0.833 0.827 0.826 1500.000
weighted avg 0.833 0.827 0.826 1500.000

modelo elegido_en_validacion exactitud f1_macro CE
0 Baseline False 0.8267 0.8274 0.4746
1 Dropout_0.2 True 0.8327 0.8343 0.4663
2 Combinado False 0.8267 0.8261 0.5136

11.17.4 Lectura de la ejecución

El procedimiento elegido con validación fue Dropout_0.2. En 1500 prendas de prueba obtuvo exactitud 83.3%, F1 macro 0.834 y CE 0.466. Las otras filas son contrastes declarados, sin una nueva selección basada en prueba.

11.17.5 De los promedios al perfil por clase

Precisión alta y recall bajo puede corresponder a una clase que el modelo predice pocas veces y con pocos falsos positivos, mientras deja escapar muchos casos verdaderos. No demuestra que el modelo haya medido su incertidumbre correctamente. F1 combina precisión y recall, pero no define el coste de los errores.

F1 macro da el mismo peso a cada clase. F1 ponderado usa su soporte. Con soportes exactamente iguales ambos promedios de F1 coinciden, aunque algunas clases sean mucho más difíciles. Una diferencia entre ellos procede de los pesos de agregación, no de heterogeneidad por sí sola.

Si Camisa se confunde con Camiseta o Abrigo, el promedio puede ocultar esa dificultad. Mirar imágenes y errores ayuda a formular otra hipótesis; usar esas etiquetas de prueba para modificar la red exigiría otra evaluación reservada. Una sola matriz no identifica causas de adquisición, ambigüedad o representación.

11.18 Ranking frente al resto: ROC-AUC no sustituye la decisión

Para el ganador fijado calculamos ROC de cada clase contra todas las demás. Se dibujan Pantalón, Bolso, Camiseta y Camisa; el AUC macro usa las diez. Un AUC alto describe ordenación de scores, mientras precisión y recall con argmax describen una decisión concreta.

El AUC de ranking aleatorio ronda 0.5; la exactitud de adivinar uniformemente entre diez clases ronda 0.10. No son pisos intercambiables. Tampoco ROC-AUC acredita calibración.

p=probas_prueba[ganador];aucs=[];fig,ax=plt.subplots(figsize=(8,5))
for k in range(10):
    fpr,tpr,_=roc_curve(yte==k,p[:,k]);a=roc_auc_score(yte==k,p[:,k]);aucs.append(a)
    if k in [1,8,0,6]:ax.plot(fpr,tpr,label=f'{NOMBRES[k]} · AUC={a:.3f}')
ax.plot([0,1],[0,1],'--',color='gray',label='Referencia aleatoria')
ax.set(xlabel='Tasa de falsos positivos',ylabel='Tasa de verdaderos positivos',title=ganador+' · ROC frente al resto')
ax.legend();plt.tight_layout();plt.show()
print('ROC-AUC macro, diez clases:',np.mean(aucs))

ROC-AUC macro, diez clases: 0.9834301234567899

11.19 Conservar el predictor y el protocolo elegidos

Guardamos arquitectura, matrices y buffers del estado seleccionado, además del contrato de entrada. La comprobación de recarga utiliza validación: no abre otra selección en prueba. Este es un artefacto de inferencia; reanudar exactamente el entrenamiento requeriría además estados de optimizador y de azar.

red=MODELOS[ganador]
recargada=guardar(red,'modelo-seleccionado',{'forma':[None,784],'escala':'uint8/255',
    'clases':NOMBRES,'seleccion':ganador,'checkpoint':INFORMES[ganador]['checkpoint']})
with torch.inference_mode():
    xb=torch.as_tensor(Xva[:32],dtype=torch.float32)
    diferencia=float((red(xb.to(dispositivo)).cpu()-recargada(xb)).abs().max())
    assert torch.allclose(red(xb.to(dispositivo)).cpu(),recargada(xb),atol=1e-5,rtol=1e-5)
print('Máxima diferencia de logits al recargar:',diferencia)
(ARTEFACTOS/'protocolo.json').write_text(json.dumps({'versiones':versiones,
    'dispositivo':str(dispositivo),'semilla':42,'n_train':N_TRAIN,'n_val':N_VAL,'n_test':N_TEST,
    'hashes_gzip':hashes_datos,'lote':LOTE,'inicializacion':'Xavier uniforme gain=1; sesgos cero',
    'seleccion_checkpoint':'CE de validacion sin penalizacion',
    'seleccion_configuracion':'CE de validacion sin penalizacion',
    'penalizacion':'sumas sobre matrices ocultas; excluye sesgos, BN y salida',
    'criterio_parada':'paciencia por min_delta; checkpoint por minimo exacto',
    'ganador':ganador,'prueba_diagnosticos':evaluados,'configuraciones_busqueda':configs,
    'recarga_max_diferencia':diferencia,'limite':'una semilla y un subconjunto; no benchmark entre frameworks'},
    ensure_ascii=False,indent=2),encoding='utf-8')
Máxima diferencia de logits al recargar: 0.0
1739

11.20 Guía diagnóstica y límites de la comparación

Observación persistente Hipótesis compatible Siguiente contraste
Entrenamiento y validación bajos Representación insuficiente, poco ajuste o regularización excesiva Revisar curvas, tasa, presupuesto y fuerza de penalización
Entrenamiento mejora y validación empeora Sobreajuste bajo este protocolo Conservar el mínimo y contrastar otra intervención en desarrollo
Una técnica reduce el gap y ambos aciertos Ajuste demasiado restringido Revisar el nivel absoluto antes de celebrar la brecha
Validación pierde CE aunque exactitud no cambie Cambiaron magnitudes de scores o errores más costosos en CE Inspeccionar distribución de pérdidas; no diagnosticar calibración sin medirla
Exactitud online inferior a evaluación con dropout Modos y momentos distintos Comparar estados fijos; no llamarlo mejora de generalización
Combinado mejora Efecto conjunto bajo este diseño No atribuirlo a una única técnica sin ablaciones

Los splits, modelos y presupuestos se han declarado. Eso no elimina variabilidad entre semillas, dependencia de la muestra ni incertidumbre de medición temporal. El esquema clásico de sesgo-varianza no se ha estimado con estas redes. Las comparaciones apoyan decisiones para este desarrollo, sin garantizar su repetición en otro conjunto.

El capítulo de evaluación amplía el análisis de métricas; fundamentos de CNN introduce una representación que aprovecha vecindarios de píxeles. Cambiar de representación es otra hipótesis, diferente de aumentar o reducir una penalización.

11.21 Taller

  1. Describe con cifras un caso donde disminuya el gap sin mejorar validación. Formula una hipótesis y una decisión que no utilice prueba.
  2. Compara dos valores nuevos de L2 con la convención de sumas declarada. Explica por qué no copiarías esos coeficientes a una penalización media o a AdamW.
  3. Cambia la paciencia a 2 y 20 con techo fijo. Distingue época final, mínimo exacto, coste y checkpoint recuperado.
  4. Diseña una ablación del combinado que separe BN de dropout manteniendo lotes y estados iniciales comparables.
  5. Repite el baseline con tres semillas prefijadas. Resume media y dispersión de validación sin escoger la mejor semilla de prueba.
  6. Explica por qué un AUC macro alto puede coexistir con un recall bajo de Camisa, y qué información adicional necesitarías antes de cambiar decisiones.

11.22 Referencias