7  Optimización y estabilidad

Una red puede tener capacidad suficiente y recibir gradientes correctos, pero aprender mal por cómo se inicializa, por la escala de sus actualizaciones o por el régimen de normalización. Antes de añadir capas conviene distinguir esos mecanismos de un problema de generalización.

Seguiremos tres preguntas: ¿qué señal llega antes de entrenar?, ¿qué ocurre al actualizar los pesos?, ¿cómo comparar reglas de optimización sin escoger con prueba? Conservaremos las derivaciones y simulaciones en NumPy; los clasificadores se entrenan con PyTorch.

7.1 Objetivos y condiciones de lectura

  • Relacionar inicialización, activación y propagación de señales sin confundir saturación con explosión.
  • Comparar inicializaciones y BatchNorm con arquitectura, datos y presupuesto declarados.
  • Medir la norma del gradiente y distinguir recorte global, por tensor y por componente.
  • Interpretar SGD, momentum, RMSProp y Adam con sus tasas y estados.
  • Seleccionar checkpoints y configuraciones con validación, reservando prueba para la evaluación final.

Se asumen tensores y autograd, MLP y retropropagación. La lectura de generalización se amplía en evaluación y regularización.

7.2 Preparación del entorno

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)
{'python': '3.13.9', 'torch': '2.14.1+cpu', 'numpy': '2.3.5', 'sklearn': '1.8.0'} | dispositivo: cpu

7.3 Antes de explicar una curva, identificar de dónde viene

Las siguientes pérdidas se construyen con fórmulas: no son entrenamientos. Ilustran descenso, oscilación y estancamiento; el NaN de la curva roja se inserta deliberadamente. Una interrupción no basta para concluir que hubo desbordamiento: hay que revisar valores, datos y operaciones.

epocas = np.arange(1, 16)
loss_ok = 1.4 * np.exp(-epocas / 5) + 0.15 + np.random.rand(15) * 0.02
loss_explota = 1.4 + np.random.rand(15) * 1.5
loss_explota[0:3] = [1.4, 2.1, np.nan]
loss_cero = np.full(15, 1.39) + np.random.rand(15) * 0.01
fig, ax = plt.subplots(1, 1, figsize=(7, 4.5))
ax.plot(epocas, loss_ok, "g-", linewidth=2, label="Descenso simulado")
ax.plot(epocas, loss_explota, "r-", linewidth=2, label="Oscilación y NaN insertado")
ax.plot(epocas, loss_cero, "b--", linewidth=2, label="Estancamiento simulado")
ax.set_xlabel("Época")
ax.set_ylabel("Pérdida")
ax.set_title("Curvas simuladas: no proceden de una red entrenada")
ax.legend()
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()

7.4 La escala antes de la primera actualización

Para una fila \(x\) y una matriz \(W\) de forma \((n_{in},n_{out})\), \(z=xW+b\). Con pesos centrados, independientes de entradas centradas y componentes aproximadamente independientes:

\[\operatorname{Var}(z_j)\approx n_{in}\sigma_W^2\operatorname{Var}(x_i).\]

Es una aproximación de las preactivaciones, no una identidad universal de toda red. Las activaciones cambian el resultado: tanh acota salidas entre −1 y 1, por lo que pesos grandes pueden saturarla sin hacer infinita su varianza. Su derivada se acerca a cero al saturarse.

Pasaremos un lote aleatorio por 25 capas de 128 unidades, sin entrenar. Se comparan desviaciones estándar 1, 0.01 y \(1/\sqrt{128}\), con los mismos números normales antes de multiplicar por cada escala.

def forward_varianzas(n_capas, n_neuronas, escala_std, n_muestras=512, semilla=0):
    """Pasa un batch aleatorio por una red de n_capas capas densas SIN entrenar
    (pesos fijos según escala_std) y mide la varianza de la activación en cada capa."""
    rng = np.random.default_rng(semilla)
    x = rng.standard_normal((n_muestras, n_neuronas)).astype("float32")
    varianzas = [float(np.var(x))]
    for _ in range(n_capas):
        W = rng.standard_normal((n_neuronas, n_neuronas)).astype("float32") * escala_std
        z = x @ W
        x = np.tanh(z)  # activación acotada, para aislar el efecto de la escala de W
        varianzas.append(float(np.var(x)))
    return varianzas
n_capas = 25
v_grande = forward_varianzas(n_capas, 128, escala_std=1.0)
v_pequena = forward_varianzas(n_capas, 128, escala_std=0.01)
v_xavier = forward_varianzas(n_capas, 128, escala_std=(1.0 / 128) ** 0.5)
print("Varianza en la capa 25 — escala grande:", round(v_grande[-1], 6))
print("Varianza en la capa 25 — escala pequeña:", round(v_pequena[-1], 6))
print("Varianza en la capa 25 — escala Xavier:", round(v_xavier[-1], 6))
Varianza en la capa 25 — escala grande: 0.928066
Varianza en la capa 25 — escala pequeña: 0.0
Varianza en la capa 25 — escala Xavier: 0.01812
fig, ax = plt.subplots(1, 1, figsize=(7, 4.5))
ax.plot(v_grande, "r-", label="std=1.0 (tanh saturada)")
ax.plot(v_pequena, "b-", label="std=0.01 (desaparece)")
ax.plot(v_xavier, "g-", label="std=Xavier")
ax.set_xlabel("Profundidad (número de capa)")
ax.set_ylabel("Varianza de la activación")
ax.set_yscale("log")
ax.set_title("Varianza de activaciones vs. profundidad, sin entrenar")
ax.legend()
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()

7.4.1 Cómo leer este resultado

La escala pequeña atenúa la señal a través de las capas. Con escala grande, tanh queda cerca de sus límites; una varianza de salida apreciable no garantiza gradientes útiles. Esta figura mide activaciones, no gradientes. El efecto sobre el entrenamiento requiere otro experimento.

7.5 Xavier y He: comprobar también la activación

Xavier propone \(\operatorname{Var}(W)=2/(n_{in}+n_{out})\). En estas capas cuadradas usamos ganancia 1 para contrastar la escala básica. Las implementaciones también permiten ganancias específicas para una activación; no todas las llamadas denominadas Xavier producen la misma varianza.

Para ReLU, bajo simetría de la preactivación, el segundo momento después de rectificar es aproximadamente la mitad del anterior. No significa que su varianza centrada se reduzca exactamente a la mitad: ReLU tiene media positiva. He propone \(\operatorname{Var}(W)=2/n_{in}\) para compensar esa propagación bajo sus supuestos.

En PyTorch explicitamos nn.init.xavier_uniform_ o nn.init.kaiming_normal_(..., nonlinearity='relu'). No asumimos que los valores predeterminados de nn.Linear sean idénticos a esos esquemas.

def forward_varianzas_relu(n_capas, n_neuronas, inicializador, n_muestras=512, semilla=0):
    rng = np.random.default_rng(semilla)
    x = rng.standard_normal((n_muestras, n_neuronas)).astype("float32")
    varianzas = [float(np.var(x))]
    for _ in range(n_capas):
        if inicializador == "xavier":
            std = (2.0 / (n_neuronas + n_neuronas)) ** 0.5
        elif inicializador == "he":
            std = (2.0 / n_neuronas) ** 0.5
        else:
            std = 1.0
        W = rng.standard_normal((n_neuronas, n_neuronas)).astype("float32") * std
        z = x @ W
        x = np.maximum(z, 0)  # ReLU
        varianzas.append(float(np.var(x)))
    return varianzas
v_xavier_relu = forward_varianzas_relu(25, 128, "xavier")
v_he_relu = forward_varianzas_relu(25, 128, "he")
print("Varianza final Xavier+ReLU:", round(v_xavier_relu[-1], 6))
print("Varianza final He+ReLU:", round(v_he_relu[-1], 6))
Varianza final Xavier+ReLU: 0.0
Varianza final He+ReLU: 0.670259
fig, ax = plt.subplots(1, 1, figsize=(7, 4.5))
ax.plot(v_xavier_relu, "orange", label="Xavier + ReLU")
ax.plot(v_he_relu, "g-", label="He + ReLU")
ax.set_xlabel("Profundidad")
ax.set_ylabel("Varianza de la activación")
ax.set_title("Xavier vs. He con ReLU, 25 capas, sin entrenar")
ax.legend()
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()

7.5.1 Qué decisión apoya esta comparación

Observa el orden de magnitud y la tendencia con profundidad. Las fluctuaciones de He entre capas recuerdan que las fórmulas son aproximaciones para una red aleatoria finita. Si la señal se atenúa desde el inicio, revisar inicialización y activación es una hipótesis más directa que aumentar capacidad. Estas curvas no garantizan estabilidad del optimizador ni buen resultado en datos nuevos.

7.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)
    if info['estado']!='finito':
        # El estado de respaldo no es un checkpoint entrenado válido.
        tr={'loss':np.nan,'accuracy':np.nan};va={'loss':np.nan,'accuracy':np.nan}
    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

7.7 Datos y particiones de los clasificadores

Usaremos Digits, imágenes 8×8 aplanadas a 64 entradas y divididas por 16, la escala documentada. Una permutación con semilla 42 asigna aproximadamente 60/20/20 a ajuste, validación y prueba. Inicialización y BatchNorm se contrastan solo en desarrollo; la comparación final de optimizadores utiliza otra partición estratificada con semilla 77, declarada al llegar a ella.

Separar ambos experimentos hace explícito su propósito. No compararemos directamente sus porcentajes como si procedieran de idénticos conjuntos y arquitecturas. Las redes de inicialización tienen cinco capas ocultas de 128 unidades y una salida de diez logits; ReLU se aplica solo a las ocultas.

datos=load_digits(); X=datos.data.astype('float32')/16.; y=datos.target.astype('int64')
ids=np.random.default_rng(42).permutation(len(y)); a=int(.6*len(y)); b=int(.8*len(y))
itr,iva,ite=ids[:a],ids[a:b],ids[b:]
assert not (set(itr)&set(iva) or set(itr)&set(ite) or set(iva)&set(ite))
Xtr,Xva,Xte=X[itr],X[iva],X[ite]; ytr,yva,yte=y[itr],y[iva],y[ite]
print('Ajuste / validación / prueba:',len(itr),len(iva),len(ite))
pd.DataFrame({'indice':ids,'particion':['train']*a+['val']*(b-a)+['test']*(len(y)-b)}).to_csv(ARTEFACTOS/'particiones-inicializacion.csv',index=False)
Ajuste / validación / prueba: 1078 359 360

7.8 Inicialización: el mismo entrenamiento, tres puntos de partida

Fijamos SGD 0.05, lote 64 y diez épocas. Se inicializan explícitamente todas las matrices, incluida la salida, como en el experimento de referencia: ceros, normal de desviación 1 o He normal. Los sesgos empiezan en cero. He en la salida se mantiene aquí por control experimental, no como recomendación universal para una capa sin ReLU.

La pérdida y norma se comprueban en cada actualización. Una corrida no finita se detiene y se identifica; no se presenta su exactitud como evidencia de un predictor aprovechable. Para los casos finitos se restaura el mínimo de validación dentro del mismo presupuesto.

redes_init={}; historias_init={}; informes_init={}; filas_init=[]
for nombre in ['cero','grande','he']:
    red=Red(64,[128]*5,10,inicializacion=nombre)
    h,info=ajustar(red,Xtr,ytr,Xva,yva,epocas=10,lote=64,lr=.05,opt='SGD',nombre=nombre)
    redes_init[nombre]=red; historias_init[nombre]=h; informes_init[nombre]=info
    filas_init.append(resumen(red,h,info,Xtr,ytr,Xva,yva))
    h.to_csv(ARTEFACTOS/('inicializacion-'+nombre+'.csv'),index=False)
tabla_init=pd.DataFrame(filas_init)
display(tabla_init[['modelo','estado','checkpoint','train_loss','val_loss','train_accuracy','val_accuracy']].round(4))
fig,ax=plt.subplots(1,3,figsize=(15,4.2))
for nombre,h in historias_init.items():
    ax[0].plot(h.epoca,h.train_loss,label=nombre)
    ax[1].plot(h.epoca,h.val_accuracy,label=nombre)
    ax[2].plot(h.epoca,h.norma_gradiente_media,label=nombre,marker='o',markersize=4)
ax[0].set(title='Pérdida al terminar cada época',ylabel='Entropía cruzada')
ax[1].set(title='Validación',ylabel='Exactitud',ylim=(0,1.03))
ax[2].set(title='Gradientes antes de actualizar',ylabel='Norma global media · lotes finitos',yscale='symlog')
for eje in ax:eje.set_xlabel('Época');eje.legend()
plt.tight_layout();plt.show()
display(Markdown('### Lectura de la ejecución\n\n'+'; '.join(
    f'{r["modelo"]}: estado {r["estado"]}, checkpoint {r["checkpoint"]}, exactitud de validación {r["val_accuracy"]:.3f}'
    for r in filas_init)+'. Las corridas no finitas describen un fallo de optimización, no una comparación válida de generalización.'))
cero | épocas: 10 | checkpoint: 1 | estado: finito
grande | épocas: 1 | checkpoint: 0 | estado: no_finito
he | épocas: 10 | checkpoint: 10 | estado: finito
modelo estado checkpoint train_loss val_loss train_accuracy val_accuracy
0 cero finito 1 2.3024 2.3031 0.1058 0.1086
1 grande no_finito 0 NaN NaN NaN NaN
2 he finito 10 0.0774 0.1198 0.9889 0.9721

7.8.1 Lectura de la ejecución

cero: estado finito, checkpoint 1, exactitud de validación 0.109; grande: estado no_finito, checkpoint 0, exactitud de validación nan; he: estado finito, checkpoint 10, exactitud de validación 0.972. Las corridas no finitas describen un fallo de optimización, no una comparación válida de generalización.

7.8.2 Del síntoma a una hipótesis

Con todas las matrices y sesgos ocultos en cero, ReLU y la simetría pueden bloquear el aprendizaje de características. La salida todavía puede cambiar sesgos y favorecer una clase frecuente. Un resultado cercano a 0.10 no prueba que todas las salidas sean aleatorias.

La normal de escala 1 puede generar preactivaciones y gradientes grandes en esta red. Mira el estado numérico y la norma, además de la pérdida. He ofrece una escala razonable bajo los supuestos anteriores, pero su resultado aquí sigue condicionado por tasa, profundidad y presupuesto. Si la corrida grande falla antes de completar una época, no hay una curva completa que interpretar.

7.9 Cambiar a Adam: qué puede y qué no puede resolver

Repetimos las inicializaciones cero y He con Adam 0.001, diez épocas y los mismos lotes. Esta comparación enfrenta configuraciones con tasas diferentes; no aísla un efecto puro del nombre del optimizador. Adam reescala gradientes usando estados acumulados, pero no crea diversidad entre unidades que reciben exactamente la misma señal.

filas_adam=[]
for nombre in ['cero','he']:
    red=Red(64,[128]*5,10,inicializacion=nombre)
    h,info=ajustar(red,Xtr,ytr,Xva,yva,epocas=10,lote=64,lr=.001,opt='Adam',nombre='Adam '+nombre)
    filas_adam.append(resumen(red,h,info,Xtr,ytr,Xva,yva)); curvas(h,info,'Adam · '+nombre)
display(pd.DataFrame(filas_adam)[['modelo','estado','train_accuracy','val_accuracy','checkpoint']].round(3))
Adam cero | épocas: 10 | checkpoint: 1 | estado: finito

Adam he | épocas: 10 | checkpoint: 9 | estado: finito

modelo estado train_accuracy val_accuracy checkpoint
0 Adam cero finito 0.106 0.067 1
1 Adam he finito 1.000 0.986 9

7.10 Profundidad y BatchNorm

Comparamos 16 capas ocultas de 64 unidades, Xavier uniforme y una salida de diez logits. Fijamos SGD 0.1 y diez épocas. El bloque con normalización utiliza Linear sin sesgo → BatchNorm → ReLU; el control utiliza Linear con sesgo → ReLU. El sesgo previo a BN es redundante con su desplazamiento, pero el número de parámetros de los dos bloques no es idéntico.

En entrenamiento, BatchNorm usa estadísticas del lote. En evaluación utiliza sus medias y varianzas móviles. PyTorch interpreta momentum=0.01 como peso de la nueva observación en la actualización; no es el momentum del optimizador. Fijamos epsilon 0.001. Sus detalles de estimación de varianza no se deben asumir idénticos a los de otra biblioteca.

La motivación histórica de desplazamiento de distribuciones no establece una causa única de sus mejoras. BatchNorm cambia la dinámica del entrenamiento y puede ayudar, empeorar o empatar bajo otra configuración. Lo comprobamos en el mismo modo de evaluación para ambos modelos.

filas_bn=[]; h_bn={}; info_bn={}
for usar_bn in [False,True]:
    nombre='con_BN' if usar_bn else 'sin_BN'
    red=Red(64,[64]*16,10,bn=usar_bn,semilla_pesos=7)
    h,info=ajustar(red,Xtr,ytr,Xva,yva,epocas=10,lote=64,lr=.1,opt='SGD',seed=7,nombre=nombre)
    filas_bn.append(resumen(red,h,info,Xtr,ytr,Xva,yva));h_bn[nombre]=h;info_bn[nombre]=info
display(pd.DataFrame(filas_bn)[['modelo','parametros','estado','checkpoint','train_accuracy','val_accuracy']].round(3))
fig,ax=plt.subplots(1,2,figsize=(11,3.5))
for nombre,h in h_bn.items():
    ax[0].plot(h.epoca,h.val_loss,label=nombre);ax[1].plot(h.epoca,h.val_accuracy,label=nombre)
ax[0].set(ylabel='Entropía cruzada',title='Validación · pérdida')
ax[1].set(ylabel='Exactitud',title='Validación · aciertos',ylim=(0,1.03))
for a in ax:a.set_xlabel('Época');a.legend()
plt.tight_layout();plt.show()
sin_BN | épocas: 10 | checkpoint: 10 | estado: finito
con_BN | épocas: 10 | checkpoint: 10 | estado: finito
modelo parametros estado checkpoint train_accuracy val_accuracy
0 sin_BN 67210 finito 10 0.208 0.145
1 con_BN 68234 finito 10 0.927 0.838

7.10.1 Cómo decidir a partir de estas curvas

Si el control permanece en un nivel bajo y BN mejora, la evidencia favorece ese bloque bajo este presupuesto, no una garantía de mayor capacidad predictiva universal. Si difieren entrenamiento y validación con BN, revisa también sus estadísticas móviles y el tamaño de los lotes: no compares medidas hechas en modos distintos.

Conservar el checkpoint incluye esos buffers. Restaurar solo matrices densas y dejar las estadísticas del último estado combinaría dos momentos del entrenamiento.

7.11 Recortar el gradiente: una operación que podemos medir

Para la norma conjunta \(g\) y umbral \(c\), el recorte global aplica \(g\min(1,c/(\|g\|+\epsilon))\). Escala todos los componentes por el mismo factor y conserva dirección cuando se activa. El recorte por valor satura componentes individualmente; el recorte por tensor permite una norma distinta para cada matriz.

El siguiente cálculo usa gradientes de normas 5 y 12: juntos tienen norma 13. Con umbral global 1, el resultado conjunto queda en 1. Si cada tensor se recorta por separado a 1, la norma conjunta queda cerca de \(\sqrt2\). Un mismo número de umbral no define la misma operación.

def gradientes_ejemplo():
    a=nn.Parameter(torch.zeros(2)); b=nn.Parameter(torch.zeros(1))
    a.grad=torch.tensor([3.,4.]); b.grad=torch.tensor([12.]); return [a,b]
g=gradientes_ejemplo(); antes=nn.utils.clip_grad_norm_(g,1.).item()
despues=torch.sqrt(sum(p.grad.square().sum() for p in g)).item()
t=gradientes_ejemplo()
for p in t: nn.utils.clip_grad_norm_([p],1.)
por_tensor=torch.sqrt(sum(p.grad.square().sum() for p in t)).item()
display(pd.DataFrame({'operación':['Sin recorte','Global c=1','Por tensor c=1'],
    'norma conjunta':[antes,despues,por_tensor]}).round(5))
assert np.isclose(antes,13.) and despues<=1.00001
assert np.isclose(por_tensor,np.sqrt(2),atol=1e-5)

filas_clip=[]
for limite in [None,1.]:
    red=Red(64,[128]*5,10,inicializacion='he')
    h,info=ajustar(red,Xtr,ytr,Xva,yva,epocas=10,lote=64,lr=.05,opt='SGD',
        clip=limite,nombre='SGD '+('sin recorte' if limite is None else 'global 1'))
    filas_clip.append({**resumen(red,h,info,Xtr,ytr,Xva,yva),
                      'fraccion_lotes_recortados':float(h.fraccion_recortada.mean())})
display(pd.DataFrame(filas_clip)[['modelo','val_loss','val_accuracy','fraccion_lotes_recortados']].round(3))
operación norma conjunta
0 Sin recorte 13.00000
1 Global c=1 1.00000
2 Por tensor c=1 1.41421
SGD sin recorte | épocas: 10 | checkpoint: 10 | estado: finito
SGD global 1 | épocas: 10 | checkpoint: 10 | estado: finito
modelo val_loss val_accuracy fraccion_lotes_recortados
0 SGD sin recorte 0.120 0.972 0.0
1 SGD global 1 0.264 0.933 1.0

7.11.1 Cómo leer el recorte

La fracción registrada responde cuántas actualizaciones se modificaron, no cuánto mejoró el modelo. Si casi todos los gradientes se recortan, quizá estamos cambiando sustancialmente el régimen de aprendizaje; si ninguno supera el umbral, no hubo intervención efectiva. La tabla permite contrastar ambas posibilidades.

Recortar se hace después de backward() y antes de step(). No corrige por sí solo un dato mal escalado ni una activación que produjo NaN antes de calcular el gradiente. En el capítulo de redes recurrentes reaparece el problema con secuencias.

7.12 Optimizar después de estabilizar: cuatro reglas de actualización

Una inicialización razonable permite que llegue señal al gradiente. El optimizador decide cómo convertir esa señal en una actualización. Usaremos \(g_t=\nabla L(\theta_{t-1})\) y una tasa \(\eta\). Los estados auxiliares del optimizador se actualizan durante el ajuste, pero no son parámetros del predictor.

Regla Estado y actualización Qué observar
SGD \(\theta_t=\theta_{t-1}-\eta g_t\) Sensibilidad a curvatura y tasa
Momentum \(v_t=\beta v_{t-1}+g_t\), \(\theta_t=\theta_{t-1}-\eta v_t\) Acumulación de dirección y oscilaciones
RMSProp \(s_t=\rho s_{t-1}+(1-\rho)g_t^2\), \(\theta_t=\theta_{t-1}-\eta g_t/(\sqrt{s_t}+\epsilon)\) Escalas distintas por coordenada
Adam Medias móviles de \(g_t\) y \(g_t^2\), corregidas por sesgo inicial Interacción entre tasa, estados y regularización

En Adam, \(m_t=\beta_1m_{t-1}+(1-\beta_1)g_t\), \(v_t=\beta_2v_{t-1}+(1-\beta_2)g_t^2\); luego \(\hat m_t=m_t/(1-\beta_1^t)\) y \(\hat v_t=v_t/(1-\beta_2^t)\). La actualización es \(\theta_t=\theta_{t-1}-\eta\hat m_t/(\sqrt{\hat v_t}+\epsilon)\). Las convenciones de momentum y ubicación de epsilon pueden variar entre bibliotecas: la tabla declara las del laboratorio manual.

7.12.1 Curvatura: el mismo gradiente no permite la misma tasa en todos los ejes

Minimizamos \(f(x,y)=(x^2+40y^2)/2\) desde (4,2), con 300 consultas exactas al gradiente. La segunda dirección tiene curvatura cuarenta veces mayor. Fijamos de antemano tasas 0.04 para SGD/momentum, 0.03 para RMSProp y 0.08 para Adam; no son tasas comparables ni óptimos buscados. El experimento explica trayectorias, no demuestra qué optimizador gana en cualquier red.

from IPython.display import display,Markdown
import pandas as pd
trayectorias_opt={};resumen_opt=[]
for nombre in ['SGD','Momentum','RMSProp','Adam']:
    theta=np.array([4.,2.]);v=np.zeros(2);s=np.zeros(2);path=[theta.copy()]
    for paso in range(1,301):
        g=theta*np.array([1.,40.])
        if nombre=='SGD':theta-=.04*g
        elif nombre=='Momentum':v=.9*v+g;theta-=.04*v
        elif nombre=='RMSProp':s=.9*s+.1*g*g;theta-=.03*g/(np.sqrt(s)+1e-8)
        else:
            v=.9*v+.1*g;s=.999*s+.001*g*g
            theta-=.08*(v/(1-.9**paso))/(np.sqrt(s/(1-.999**paso))+1e-8)
        path.append(theta.copy())
    a=np.array(path);loss=.5*(a[:,0]**2+40*a[:,1]**2);trayectorias_opt[nombre]=(a,loss)
    resumen_opt.append({'Optimizador':nombre,'Consultas':300,'f_final':loss[-1],'Norma_gradiente':np.linalg.norm(theta*np.array([1.,40.]))})
fig,ax=plt.subplots(1,2,figsize=(12,4));xx,yy=np.meshgrid(np.linspace(-5,5,150),np.linspace(-3,3,150))
ax[0].contour(xx,yy,.5*(xx**2+40*yy**2),levels=[.1,.5,2,10,40,100],colors='gray',alpha=.4)
for nombre,(a,l) in trayectorias_opt.items():ax[0].plot(a[:,0],a[:,1],label=nombre);ax[1].semilogy(np.maximum(l,1e-15),label=nombre)
ax[0].set(title='Trayectorias en el valle',xlabel='x',ylabel='y');ax[1].set(title='Mismo presupuesto de gradientes',xlabel='Actualizaciones',ylabel='f(x,y), escala logarítmica')
for eje in ax:eje.legend()
plt.tight_layout();plt.show();display(pd.DataFrame(resumen_opt))
display(Markdown('### Cómo leer este resultado\n\n'+ '; '.join(f'{r["Optimizador"]}: f final {r["f_final"]:.3g}' for r in resumen_opt)+'. Una trayectoria oscilante no equivale a una solución mejor o peor por su aspecto. RMSProp puede mantener oscilaciones cerca del mínimo con tasa fija; momentum conserva memoria de direcciones previas. Cambiar la tasa o el presupuesto puede cambiar el orden.'))

Optimizador Consultas f_final Norma_gradiente
0 SGD 300 1.844293e-10 0.000019
1 Momentum 300 2.540758e-12 0.000014
2 RMSProp 300 4.630865e-03 0.600556
3 Adam 300 3.623868e-12 0.000014

7.12.2 Cómo leer este resultado

SGD: f final 1.84e-10; Momentum: f final 2.54e-12; RMSProp: f final 0.00463; Adam: f final 3.62e-12. Una trayectoria oscilante no equivale a una solución mejor o peor por su aspecto. RMSProp puede mantener oscilaciones cerca del mínimo con tasa fija; momentum conserva memoria de direcciones previas. Cambiar la tasa o el presupuesto puede cambiar el orden.

7.13 Cuatro configuraciones sobre Digits y tres semillas

La MLP 64→32→10 con ReLU utiliza He normal y particiones estratificadas 60/20/20 con semilla 77. Para cada semilla de pesos 3, 7 y 11, las cuatro configuraciones comienzan con las mismas matrices y ven el mismo orden de lotes. Fijamos doce épocas, lote 64 y restauración del mínimo de validación.

Las tasas prefijadas son SGD 0.03, momentum 0.03 con factor 0.9, RMSProp 0.001 con alpha 0.9, y Adam 0.001. Epsilon es 1e-7 en los adaptativos. Las convenciones y estados se declaran; no se presume equivalencia bit a bit entre bibliotecas.

Elegimos por media de la pérdida de validación de las tres semillas. Después evaluamos esa configuración con sus tres estados en prueba, sin elegir la semilla de mayor acierto. Tres repeticiones muestran cierta variabilidad de inicialización; no describen incertidumbre entre poblaciones ni splits.

ids=np.arange(len(y))
tr_o,res_o=train_test_split(ids,test_size=.4,stratify=y,random_state=77)
va_o,te_o=train_test_split(res_o,test_size=.5,stratify=y[res_o],random_state=77)
registros_opt=[]; estados_opt={}; historias_opt={}
for s in [3,7,11]:
    for nombre in ['SGD','Momentum','RMSProp','Adam']:
        red=Red(64,[32],10,inicializacion='he',semilla_pesos=s)
        lr=.03 if nombre in ['SGD','Momentum'] else .001
        h,info=ajustar(red,X[tr_o],y[tr_o],X[va_o],y[va_o],epocas=12,lote=64,
            lr=lr,opt=nombre,seed=s,nombre=f'{nombre} · semilla {s}')
        va=evaluar(red,X[va_o],y[va_o])
        registros_opt.append({'optimizador':nombre,'semilla':s,'val_loss':va['loss'],
            'val_accuracy':va['accuracy'],'checkpoint':info['checkpoint'],'pasos':info['pasos']})
        estados_opt[nombre,s]=deepcopy(red.state_dict());historias_opt[nombre,s]=h
tabla_opt=pd.DataFrame(registros_opt)
agrupada=tabla_opt.groupby('optimizador').agg(val_loss_media=('val_loss','mean'),
    val_loss_sd=('val_loss','std'),val_accuracy=('val_accuracy','mean')).sort_values('val_loss_media')
elegido=agrupada.index[0]; prueba=[]
for s in [3,7,11]:
    red=Red(64,[32],10,inicializacion='he',semilla_pesos=s).to(dispositivo)
    red.load_state_dict(estados_opt[elegido,s]); ev=evaluar(red,X[te_o],y[te_o])
    prueba.append({'optimizador':elegido,'semilla':s,'test_loss':ev['loss'],'test_accuracy':ev['accuracy']})
display(agrupada.round(4));display(pd.DataFrame(prueba).round(4))
fig,ax=plt.subplots(1,2,figsize=(11,4))
for nombre in ['SGD','Momentum','RMSProp','Adam']:
    a=np.array([historias_opt[nombre,s].val_loss for s in [3,7,11]])
    ax[0].plot(np.arange(1,13),a.mean(0),label=nombre)
    ax[0].fill_between(np.arange(1,13),a.mean(0)-a.std(0,ddof=1),a.mean(0)+a.std(0,ddof=1),alpha=.12)
agrupada.val_loss_media.plot.bar(ax=ax[1],yerr=agrupada.val_loss_sd,capsize=4,rot=20)
ax[0].set(xlabel='Época',ylabel='Entropía cruzada de validación',title='Media ± desviación entre semillas');ax[0].legend()
ax[1].set(title='Configuraciones con tasas prefijadas',ylabel='Pérdida de validación')
plt.tight_layout();plt.show()
tabla_opt.to_csv(ARTEFACTOS/'comparacion-optimizadores.csv',index=False)
pd.DataFrame(prueba).to_csv(ARTEFACTOS/'prueba-optimizador.csv',index=False)
pd.DataFrame({'indice':np.r_[tr_o,va_o,te_o],
    'particion':['train']*len(tr_o)+['val']*len(va_o)+['test']*len(te_o)}).to_csv(ARTEFACTOS/'particiones-optimizadores.csv',index=False)
display(Markdown(f'### Lectura de la ejecución\n\nSe eligió **{elegido}** por pérdida media de validación '
    f'**{agrupada.iloc[0].val_loss_media:.3f}**. La exactitud media de sus tres estados en prueba es '
    f'**{np.mean([p["test_accuracy"] for p in prueba]):.1%}**. Las bandas son desviaciones entre tres ejecuciones, '
    'no intervalos de confianza. El resultado compara configuraciones concretas, no una clasificación universal de algoritmos.'))
# Guardamos la semilla 3, prefijada, sin seleccionar por prueba.
red_guardada=Red(64,[32],10,inicializacion='he',semilla_pesos=3).to(dispositivo)
red_guardada.load_state_dict(estados_opt[elegido,3]);red_guardada.eval()
recargada=guardar(red_guardada,'optimizador-seleccionado',{'forma':[None,64],'escala':'pixel/16','semilla':3})
with torch.inference_mode():
    xb=torch.as_tensor(X[va_o[:16]],dtype=torch.float32)
    assert torch.allclose(red_guardada(xb.to(dispositivo)).cpu(),recargada(xb),atol=1e-5,rtol=1e-5)
(ARTEFACTOS/'protocolo.json').write_text(json.dumps({'versiones':versiones,'dispositivo':str(dispositivo),
    'semillas_optimizadores':[3,7,11],'split_optimizadores':77,'seleccion':'media de CE de validacion',
    'epocas_optimizadores':12,'lote':64,'modelo_inferencia_semilla':3,'elegido':elegido},indent=2),encoding='utf-8')
tabla_init.to_csv(ARTEFACTOS/'resultados-inicializacion.csv',index=False)
pd.DataFrame(filas_bn).to_csv(ARTEFACTOS/'resultados-batchnorm.csv',index=False)
pd.DataFrame(filas_clip).to_csv(ARTEFACTOS/'resultados-recorte.csv',index=False)
SGD · semilla 3 | épocas: 12 | checkpoint: 12 | estado: finito
Momentum · semilla 3 | épocas: 12 | checkpoint: 12 | estado: finito
RMSProp · semilla 3 | épocas: 12 | checkpoint: 12 | estado: finito
Adam · semilla 3 | épocas: 12 | checkpoint: 12 | estado: finito
SGD · semilla 7 | épocas: 12 | checkpoint: 12 | estado: finito
Momentum · semilla 7 | épocas: 12 | checkpoint: 12 | estado: finito
RMSProp · semilla 7 | épocas: 12 | checkpoint: 12 | estado: finito
Adam · semilla 7 | épocas: 12 | checkpoint: 12 | estado: finito
SGD · semilla 11 | épocas: 12 | checkpoint: 12 | estado: finito
Momentum · semilla 11 | épocas: 12 | checkpoint: 12 | estado: finito
RMSProp · semilla 11 | épocas: 12 | checkpoint: 12 | estado: finito
Adam · semilla 11 | épocas: 12 | checkpoint: 12 | estado: finito
val_loss_media val_loss_sd val_accuracy
optimizador
Momentum 0.1732 0.0122 0.9536
Adam 0.7215 0.0460 0.8914
RMSProp 0.7325 0.0698 0.8802
SGD 1.3188 0.2026 0.7298
optimizador semilla test_loss test_accuracy
0 Momentum 3 0.1362 0.9750
1 Momentum 7 0.1284 0.9722
2 Momentum 11 0.1524 0.9694

7.13.1 Lectura de la ejecución

Se eligió Momentum por pérdida media de validación 0.173. La exactitud media de sus tres estados en prueba es 97.2%. Las bandas son desviaciones entre tres ejecuciones, no intervalos de confianza. El resultado compara configuraciones concretas, no una clasificación universal de algoritmos.

7.14 Schedulers, regularización y presupuesto

Un scheduler cambia la tasa durante el ajuste: por hitos, descenso suave o meseta de validación. Cambiar simultáneamente tasa, lote y épocas dificulta atribuir diferencias. Registra actualizaciones y ejemplos vistos, además de épocas. Acumular gradientes aproxima un lote mayor si se ponderan correctamente las pérdidas; estadísticas de BatchNorm o aleatoriedad de dropout pueden impedir equivalencia exacta.

L2 agrega una penalización al objetivo. Weight decay desacoplado contrae los pesos mediante otra operación; con precondicionamiento adaptativo no equivale en general a añadir L2 al gradiente. AdamW permite estudiar esa diferencia. Los coeficientes no se trasladan sin revisar reducción de la pérdida, escala y parámetros penalizados.

Una siguiente comparación puede utilizar presupuestos de búsqueda iguales en validación para cada algoritmo. Este capítulo no hizo esa búsqueda: sus tasas estaban fijadas. Un fallo numérico, una mala escala y una mala generalización requieren diagnósticos distintos.

7.15 Taller

  1. Mide segundos momentos y varianzas centradas en el ejemplo ReLU. Explica por qué no son iguales y qué aproxima la derivación de He.
  2. Compara tres umbrales de recorte con los mismos datos y semillas. Selecciona con validación y registra frecuencia y magnitud de las modificaciones.
  3. Repite la comparación BN con dos tamaños de lote manteniendo explícito el número de actualizaciones. Examina estadísticas móviles, además de exactitud.
  4. Diseña un presupuesto de búsqueda igual para las tasas de los cuatro optimizadores. Fija el criterio antes de consultar una prueba nueva.

7.16 Referencias