13  Clasificación con CNN

Serie Deep Learning · del perceptrón a los transformers.

Entrenaremos una CNN sobre MNIST, elegiremos su checkpoint con validación y leeremos métricas por clase y errores. Cerraremos el recorrido guardando el contrato de preprocesamiento y comprobando la inferencia de un modelo reconstruido desde disco. Una exactitud alta en dígitos no demuestra utilidad en imágenes de otros dominios.

13.1 1. Entorno y protocolo

Usamos PyTorch para las redes, NumPy para cálculos explícitos y scikit-learn para particiones y métricas. El orden de importación evita conflictos entre bibliotecas numéricas en algunos entornos Windows. La caché histórica de datos se lee sin depender de su framework de origen.

Dependencias: PyTorch, torchvision, NumPy, pandas, Matplotlib, scikit-learn y Pillow. Instálelas en el mismo entorno del kernel de Jupyter (python -m pip install torch torchvision numpy pandas matplotlib scikit-learn pillow). La ejecución registrada usa CPU; el código selecciona CUDA cuando está disponible. Los pesos preentrenados y los datos que no estén en caché requieren acceso a sus servidores oficiales.

Código
from pathlib import Path
import json, time, copy, hashlib, platform, gzip, struct, urllib.request
import numpy as np
import pandas as pd
import sklearn
from sklearn.model_selection import train_test_split
from sklearn.metrics import classification_report, confusion_matrix, f1_score
import matplotlib.pyplot as plt
from IPython.display import display, Markdown
from PIL import Image
import torch
from torch import nn
from torch.nn import functional as F
from torch.utils.data import DataLoader, TensorDataset, Dataset

SEED=42
np.random.seed(SEED); torch.manual_seed(SEED)
torch.set_num_threads(2)
DEVICE=torch.device('cuda' if torch.cuda.is_available() else 'cpu')
ARTEFACTOS=Path('artefactos_pytorch'); ARTEFACTOS.mkdir(exist_ok=True)
plt.style.use('seaborn-v0_8-whitegrid')
plt.rcParams.update({'figure.dpi':110,'font.size':10})
print({'python':platform.python_version(),'torch':str(torch.__version__),
       'numpy':np.__version__,'sklearn':sklearn.__version__,'dispositivo':str(DEVICE),
       'hilos_cpu':torch.get_num_threads(),'semilla':SEED})

def semilla(n=SEED):
    np.random.seed(n); torch.manual_seed(n)

def parametros(modelo):
    return sum(p.numel() for p in modelo.parameters())

def inicializar(modelo):
    # Conv y capas ocultas ReLU: He; salida lineal: Xavier explícito.
    for m in modelo.modules():
        if isinstance(m,(nn.Conv2d,nn.Linear)):
            nn.init.kaiming_normal_(m.weight,nonlinearity='relu')
            if m.bias is not None: nn.init.zeros_(m.bias)
    lineales=[m for m in modelo.modules() if isinstance(m,nn.Linear)]
    if lineales: nn.init.xavier_uniform_(lineales[-1].weight)
    return modelo.to(DEVICE)

def conjunto(x,y):
    return TensorDataset(torch.as_tensor(np.ascontiguousarray(x),dtype=torch.float32),
                         torch.as_tensor(np.asarray(y),dtype=torch.long))

def cargador(ds,lote=128,mezclar=False):
    return DataLoader(ds,batch_size=lote,shuffle=mezclar,num_workers=0,
                      generator=torch.Generator().manual_seed(SEED))

@torch.inference_mode()
def evaluar(modelo,ds,lote=128):
    modelo.eval(); perdidas=0.; aciertos=0; logits=[]; etiquetas=[]
    for x,y in cargador(ds,lote):
        x=x.to(DEVICE);y=y.to(DEVICE);z=modelo(x)
        perdidas+=F.cross_entropy(z,y,reduction='sum').item()
        aciertos+=(z.argmax(1)==y).sum().item()
        logits.append(z.cpu());etiquetas.append(y.cpu())
    z=torch.cat(logits);y=torch.cat(etiquetas)
    assert torch.isfinite(z).all(), 'Logits no finitos'
    return {'ce':perdidas/len(ds),'exactitud':aciertos/len(ds),
            'logits':z.numpy(),'y':y.numpy(),'probabilidades':z.softmax(1).numpy()}

def entrenar(modelo,train,val,epocas,lote=128,lr=1e-3,paciencia=None,nombre='modelo',
             modo_train=None,incluir_inicial=False):
    # El optimizador se construye DESPUÉS de fijar requires_grad.
    optimizador=torch.optim.Adam([p for p in modelo.parameters() if p.requires_grad],lr=lr)
    dl=cargador(train,lote,True); historial=[]; mejor=float('inf'); pesos=None; espera=0
    inicio=time.perf_counter();epoca_mejor=None
    if incluir_inicial:
        inicial=evaluar(modelo,val,lote); mejor=inicial['ce']
        pesos=copy.deepcopy(modelo.state_dict());epoca_mejor=0
    for epoca in range(1,epocas+1):
        modelo.train()
        if modo_train is not None: modo_train(modelo)
        suma=0.;aciertos=0
        for x,y in dl:
            x=x.to(DEVICE);y=y.to(DEVICE)
            optimizador.zero_grad(set_to_none=True)
            z=modelo(x);perdida=F.cross_entropy(z,y)
            if not torch.isfinite(perdida): raise FloatingPointError(nombre+' pérdida no finita')
            perdida.backward();optimizador.step()
            suma+=perdida.item()*len(y);aciertos+=(z.argmax(1)==y).sum().item()
        # Comparar curvas en el MISMO modo eval y al cierre de cada época.
        tr=evaluar(modelo,train,lote);va=evaluar(modelo,val,lote)
        historial.append({'epoca':epoca,'ce_online':suma/len(train),
             'acc_online':aciertos/len(train),'ce_train':tr['ce'],'ce_val':va['ce'],
             'acc_train':tr['exactitud'],'acc_val':va['exactitud']})
        if va['ce']<mejor:
            mejor=va['ce'];pesos=copy.deepcopy(modelo.state_dict());epoca_mejor=epoca;espera=0
        else: espera+=1
        print(f"{nombre} {epoca}/{epocas}: CE val={va['ce']:.4f}, acc val={va['exactitud']:.4f}",flush=True)
        if paciencia is not None and espera>=paciencia: break
    modelo.load_state_dict(pesos);modelo.eval()
    h=pd.DataFrame(historial);h.to_csv(ARTEFACTOS/f'{nombre}-historial.csv',index=False)
    resumen={'modelo':nombre,'mejor_epoca':epoca_mejor,'epocas':len(h),'ce_val':mejor,
             'segundos':time.perf_counter()-inicio,'parametros':parametros(modelo),
             'entrenables':sum(p.numel() for p in modelo.parameters() if p.requires_grad)}
    (ARTEFACTOS/f'{nombre}-entrenamiento.json').write_text(json.dumps(resumen,indent=2),encoding='utf-8')
    return h,resumen

def curvas(h,titulo,mejor=None):
    fig,axes=plt.subplots(1,2,figsize=(11,3.8))
    for ax,a,b,t in [(axes[0],'ce_train','ce_val','Entropía cruzada'),
                     (axes[1],'acc_train','acc_val','Exactitud')]:
        ax.plot(h.epoca,h[a],'-o',label='Entrenamiento (eval)',ms=3)
        ax.plot(h.epoca,h[b],'-o',label='Validación',ms=3)
        if mejor is not None: ax.axvline(mejor,color='.4',ls='--',label=f'Checkpoint {mejor}')
        ax.set(xlabel='Época',ylabel=t);ax.legend(fontsize=8)
    fig.suptitle(titulo);fig.tight_layout();plt.show()

def lectura(h,res):
    e=res['mejor_epoca'];ultima=h.iloc[-1]
    texto=(f"**Lectura del experimento.** Se ejecutaron {len(h)} épocas; el checkpoint seleccionado "
           f"por CE de validación corresponde a la época {e}, con CE {res['ce_val']:.4f}. "
           f"Al cierre, la brecha de exactitud train−val es "
           f"{100*(ultima.acc_train-ultima.acc_val):+.2f} puntos porcentuales. "
           "Una brecha describe este conjunto y este presupuesto; no identifica por sí sola su causa. ")
    if e==0: texto+='El ajuste fino no mejoró la CE inicial: se conserva el punto de partida de la fase congelada.'
    elif e<len(h): texto+='La última época no es el modelo recuperado: prolongar este ensayo no redujo el mínimo de validación.'
    else: texto+='El mínimo está en el límite del presupuesto; no demuestra que más épocas mejorarían el resultado.'
    display(Markdown(texto))

def diagnostico(resultado,nombres,imagenes=None,titulo='Prueba'):
    y=resultado['y'];p=resultado['probabilidades'];pred=p.argmax(1);k=len(nombres)
    print(classification_report(y,pred,labels=np.arange(k),target_names=nombres,digits=4,zero_division=0))
    cm=confusion_matrix(y,pred,labels=np.arange(k));normal=cm/np.maximum(cm.sum(1,keepdims=True),1)
    fig,ax=plt.subplots(figsize=(7,5.5));im=ax.imshow(normal,vmin=0,vmax=1,cmap='Blues')
    ax.set_xticks(range(k),nombres,rotation=55,ha='right',fontsize=8)
    ax.set_yticks(range(k),nombres,fontsize=8);ax.set(xlabel='Predicho',ylabel='Real',title=titulo+' · proporción por clase')
    for i in range(k):
        for j in range(k):
            if cm[i,j]: ax.text(j,i,str(cm[i,j]),ha='center',va='center',fontsize=7,color='white' if normal[i,j]>.5 else 'black')
    fig.colorbar(im,ax=ax,label='Proporción de la clase real');fig.tight_layout();plt.show()
    err=np.flatnonzero(y!=pred)
    peor=int(np.argmin(np.diag(normal)))
    fuera=cm.copy();np.fill_diagonal(fuera,0);i,j=np.unravel_index(fuera.argmax(),fuera.shape)
    display(Markdown(f"**Decisión después de medir.** Exactitud {resultado['exactitud']:.2%}; "
        f"macro-F1 {f1_score(y,pred,average='macro'):.4f}; {len(err)} errores de {len(y)}. "
        f"El menor recall corresponde a **{nombres[peor]}** ({normal[peor,peor]:.2%}). "
        +(f"La confusión más frecuente es {nombres[i]} → {nombres[j]} ({fuera[i,j]} casos). " if len(err) else '')+
        "Esto orienta una revisión de etiquetas, cobertura y costes por clase; no autoriza volver a seleccionar con este test."))
    if imagenes is not None and len(err):
        confianza=p.max(1);orden=err[np.argsort(-confianza[err])][:8]
        fig,axes=plt.subplots(2,4,figsize=(11,5))
        for ax,idx in zip(axes.flat,orden):
            img=np.asarray(imagenes[idx]);img=np.moveaxis(img,0,-1) if img.ndim==3 and img.shape[0] in [1,3] else img
            ax.imshow(img.squeeze(),cmap='gray' if img.squeeze().ndim==2 else None)
            ax.set_title(f'Real: {nombres[y[idx]]}\nPred: {nombres[pred[idx]]} · p={confianza[idx]:.2f}',fontsize=8);ax.axis('off')
        for ax in list(axes.flat)[len(orden):]: ax.axis('off')
        fig.suptitle('Errores de mayor probabilidad predicha · no es una medida de calibración')
        fig.tight_layout();plt.show()
    return cm

def guardar(modelo,fabrica,nombre,contrato,val):
    ruta=ARTEFACTOS/f'{nombre}.pt'
    estado={k:v.detach().cpu().clone() for k,v in modelo.state_dict().items()}
    torch.save({'state_dict':estado,'contrato':contrato,'torch':str(torch.__version__)},ruta)
    cargado=fabrica().to(DEVICE)
    paquete=torch.load(ruta,map_location=DEVICE,weights_only=True)
    cargado.load_state_dict(paquete['state_dict']);cargado.eval()
    # Comprobar recarga sobre validación; nunca seleccionar con prueba.
    muestra=next(iter(cargador(val,32)))[0].to(DEVICE);modelo.eval()
    with torch.inference_mode(): torch.testing.assert_close(modelo(muestra),cargado(muestra),rtol=1e-5,atol=1e-6)
    contrato=dict(contrato,sha256=hashlib.sha256(ruta.read_bytes()).hexdigest(),bytes=ruta.stat().st_size)
    (ARTEFACTOS/f'{nombre}-contrato.json').write_text(json.dumps(contrato,indent=2,ensure_ascii=False),encoding='utf-8')
    print('Recarga verificada:',ruta,'bytes:',ruta.stat().st_size)
    return cargado

def mnist(nombre='mnist'):
    cache=Path.home()/'.keras'/'datasets'
    if nombre=='mnist' and (cache/'mnist.npz').exists():
        with np.load(cache/'mnist.npz',allow_pickle=False) as d:
            return d['x_train'],d['y_train'],d['x_test'],d['y_test']
    from torchvision.datasets import MNIST,FashionMNIST
    clase=MNIST if nombre=='mnist' else FashionMNIST
    # Leer la caché histórica IDX sin importar TensorFlow.
    if nombre=='fashion' and (cache/'fashion-mnist'/'train-images-idx3-ubyte.gz').exists():
        def idx(f,imagenes):
            with gzip.open(cache/'fashion-mnist'/f,'rb') as h: b=h.read()
            magic,n=struct.unpack('>II',b[:8]);assert magic==(2051 if imagenes else 2049)
            return np.frombuffer(b,dtype=np.uint8,offset=16 if imagenes else 8).reshape((n,28,28) if imagenes else (n,)).copy()
        return (idx('train-images-idx3-ubyte.gz',True),idx('train-labels-idx1-ubyte.gz',False),
                idx('t10k-images-idx3-ubyte.gz',True),idx('t10k-labels-idx1-ubyte.gz',False))
    a=clase(str(Path.home()/'.cache'/'bit-datasets'),train=True,download=True)
    b=clase(str(Path.home()/'.cache'/'bit-datasets'),train=False,download=True)
    return a.data.numpy(),a.targets.numpy(),b.data.numpy(),b.targets.numpy()

def galeria(x,y,nombres,titulo):
    fig,axes=plt.subplots(2,5,figsize=(11,4.5))
    for i,ax in enumerate(axes.flat):
        j=np.flatnonzero(y==i%len(nombres))[0];img=x[j]
        if img.ndim==3 and img.shape[0] in [1,3]: img=np.moveaxis(img,0,-1)
        ax.imshow(img.squeeze(),cmap='gray' if img.squeeze().ndim==2 else None)
        ax.set_title(nombres[i%len(nombres)],fontsize=9);ax.axis('off')
    fig.suptitle(titulo);fig.tight_layout();plt.show()
{'python': '3.13.9', 'torch': '2.14.1+cpu', 'numpy': '2.3.5', 'sklearn': '1.8.0', 'dispositivo': 'cpu', 'hilos_cpu': 2, 'semilla': 42}

13.1.1 Un contrato de entrenamiento que podemos inspeccionar

nn.Module produce logits de forma (N, K). CrossEntropyLoss recibe esos logits y etiquetas enteras long de forma (N,); aplica internamente el cálculo estable equivalente a log-softmax y entropía cruzada. No añadimos softmax antes de la pérdida ni convertimos las etiquetas a one-hot. Softmax se utiliza al interpretar la predicción.

Cada lote ejecuta zero_grad → forward → backward → step. train() activa dropout y el comportamiento de entrenamiento de BatchNorm; eval() cambia esos comportamientos y inference_mode() evita construir el grafo. Son operaciones distintas. Las curvas train/val se calculan al final de la época, ambas en eval. Se conserva además la pérdida online, que usa pesos cambiantes y dropout: no se debe comparar como si fuera la misma medición.

El checkpoint minimiza exclusivamente la CE de validación, copia parámetros y buffers de state_dict y se restaura antes del informe. La prueba queda fuera de la función de entrenamiento. El tiempo incluye las mediciones de cada época, pero excluye descargas. Una semilla fija reduce variación; no sustituye varias repeticiones ni garantiza identidad entre versiones, dispositivos o frameworks.

Los historiales, contratos y pesos se guardan en artefactos_pytorch/. La recarga reconstruye la arquitectura y comprueba logits sobre validación. El archivo de pesos no incluye una sesión de optimización para reanudar exactamente un entrenamiento.

13.2 2. Datos, exploración y particiones

MNIST contiene 60 000 imágenes de desarrollo y 10 000 de prueba, de 28×28 píxeles. Conservamos el protocolo del capítulo anterior a la migración: las primeras 5 000 imágenes de desarrollo forman validación, las 55 000 restantes entrenamiento y el test oficial se mantiene. Esta reserva histórica no es estratificada; mostramos sus conteos en lugar de asumir idéntico balance.

La entrada pasa de (N,28,28) uint8 a (N,1,28,28) float32 en [0,1]. La etiqueta sigue siendo un entero: el contrato es logits y entropía cruzada, no one-hot. Normalizar no exige aprender ninguna estadística del test.

x,y,xt,yt=mnist();nombres=[str(i) for i in range(10)]
galeria(x,y,nombres,'MNIST · ejemplos de los diez dígitos')
itr=np.arange(5000,len(x));iva=np.arange(5000)
train=conjunto(x[itr,None].astype(np.float32)/255,y[itr]);val=conjunto(x[iva,None].astype(np.float32)/255,y[iva]);test=conjunto(xt[:,None].astype(np.float32)/255,yt)
np.savez_compressed(ARTEFACTOS/'particiones.npz',train=itr,val=iva,test=np.arange(len(xt)))
conteos=pd.DataFrame({'train':np.bincount(y[itr],minlength=10),'val':np.bincount(y[iva],minlength=10),'test':np.bincount(yt,minlength=10)},index=nombres)
display(conteos);conteos.div(conteos.sum()).plot.bar(figsize=(10,3.5));plt.ylabel('Proporción en la partición');plt.xlabel('Dígito');plt.title('Balance observado');plt.tight_layout();plt.show()
print('NCHW y etiquetas:',train.tensors[0].shape,train.tensors[1].shape,train.tensors[1].dtype)

train val test
0 5444 479 980
1 6179 563 1135
2 5470 488 1032
3 5638 493 1010
4 5307 535 982
5 4987 434 892
6 5417 501 958
7 5715 550 1028
8 5389 462 974
9 5454 495 1009

NCHW y etiquetas: torch.Size([55000, 1, 28, 28]) torch.Size([55000]) torch.int64

13.3 3. La operación y su sesgo espacial

Una neurona densa combina todas sus entradas. Un filtro combina un parche y reutiliza sus pesos en posiciones distintas. Esa restricción reduce conexiones libres y conserva localidad. No es invariancia absoluta: la convolución es equivariante a ciertas traslaciones, con límites por padding y muestreo. Pooling y stride pueden alterar la respuesta a desplazamientos pequeños.

Repetimos el cálculo manual 5×5 con un filtro 3×3 y lo contrastamos con F.conv2d. Una coincidencia numérica prueba el cálculo local, no que el filtro elegido sea útil para clasificar. Los filtros útiles se ajustarán durante el entrenamiento.

a=np.array([[0,0,.2,0,0],[0,.2,.8,.2,0],[.2,.8,1,.8,.2],[0,.2,.8,.2,0],[0,0,.2,0,0]],dtype=np.float32)
k=np.array([[-1,0,1],[-1,0,1],[-1,0,1]],dtype=np.float32)
manual=np.array([[np.sum(a[i:i+3,j:j+3]*k) for j in range(3)] for i in range(3)])
z=F.conv2d(torch.tensor(a)[None,None],torch.tensor(k)[None,None])[0,0].numpy()
np.testing.assert_allclose(z,manual,atol=1e-7)
print('Primera salida:',np.sum(a[:3,:3]*k));print('Mapa:\n',manual)
fig,axes=plt.subplots(1,3,figsize=(9,3))
for ax,m,t in zip(axes,[a,k,z],['Entrada 5×5','Kernel 3×3','Salida 3×3']): im=ax.imshow(m,cmap='coolwarm');ax.set_title(t);fig.colorbar(im,ax=ax,fraction=.046)
fig.tight_layout();plt.show()
Primera salida: 1.8
Mapa:
 [[ 1.8000000e+00  0.0000000e+00 -1.8000000e+00]
 [ 2.4000001e+00  1.4901161e-08 -2.4000001e+00]
 [ 1.8000001e+00  1.4901161e-08 -1.8000001e+00]]

13.4 4. Arquitectura y flujo de dimensiones

Conservamos la arquitectura de dos convoluciones valid, con 32 y 64 filtros, pooling 2, capa densa de 128 y dropout 0,5. La secuencia espacial es 28 → 26 → 13 → 11 → 5: el aplanado tiene 64×5×5 = 1600 entradas. La última capa produce diez logits.

Dropout anula unidades aleatoriamente durante entrenamiento; se desactiva en evaluación. Por eso una exactitud online menor que la de validación no demuestra una fuga de datos. La tabla muestra forma y parámetros calculados con un lote de dos imágenes. El coste de la capa densa explica por qué no basta contar filtros.

def crear_cnn():
    return nn.Sequential(nn.Conv2d(1,32,3),nn.ReLU(),nn.MaxPool2d(2),nn.Conv2d(32,64,3),nn.ReLU(),nn.MaxPool2d(2),nn.Flatten(),nn.Linear(1600,128),nn.ReLU(),nn.Dropout(.5),nn.Linear(128,10))
semilla();modelo=inicializar(crear_cnn());modelo.eval();z=torch.zeros(2,1,28,28,device=DEVICE);filas=[]
with torch.inference_mode():
    for capa in modelo:
        z=capa(z);filas.append({'capa':type(capa).__name__,'salida':str(tuple(z.shape)),'parametros':parametros(capa)})
display(pd.DataFrame(filas));assert tuple(z.shape)==(2,10)
esperados=320+18496+(1600+1)*128+(128+1)*10
assert parametros(modelo)==esperados
print('Parámetros:',esperados)
capa salida parametros
0 Conv2d (2, 32, 26, 26) 320
1 ReLU (2, 32, 26, 26) 0
2 MaxPool2d (2, 32, 13, 13) 0
3 Conv2d (2, 64, 11, 11) 18496
4 ReLU (2, 64, 11, 11) 0
5 MaxPool2d (2, 64, 5, 5) 0
6 Flatten (2, 1600) 0
7 Linear (2, 128) 204928
8 ReLU (2, 128) 0
9 Dropout (2, 128) 0
10 Linear (2, 10) 1290
Parámetros: 225034

13.5 5. Entrenamiento y checkpoint

Se conserva el máximo de veinte épocas, lote 128, Adam con tasa 0,001 y paciencia 3. La parada cuenta épocas sin nuevo mínimo exacto de CE de validación. No se ajustan arquitectura ni tasa después de ver prueba. El experimento puede detenerse antes del límite; el informe especifica lo que ocurrió.

Una CE creciente junto con exactitud estable puede indicar errores cada vez más seguros. La brecha tampoco identifica por sí sola memorización: hay que revisar dificultad de las particiones, ruido, regularización y varias ejecuciones antes de atribuir causas.

h,res=entrenar(modelo,train,val,20,lote=128,lr=1e-3,paciencia=3,nombre='mnist-cnn')
curvas(h,'MNIST · checkpoint por CE de validación',res['mejor_epoca']);lectura(h,res)
print('Pérdida online final:',h.ce_online.iloc[-1],'· pérdida train en eval:',h.ce_train.iloc[-1])
mnist-cnn 1/20: CE val=0.0653, acc val=0.9794
mnist-cnn 2/20: CE val=0.0478, acc val=0.9860
mnist-cnn 3/20: CE val=0.0425, acc val=0.9886
mnist-cnn 4/20: CE val=0.0409, acc val=0.9884
mnist-cnn 5/20: CE val=0.0347, acc val=0.9900
mnist-cnn 6/20: CE val=0.0341, acc val=0.9902
mnist-cnn 7/20: CE val=0.0342, acc val=0.9902
mnist-cnn 8/20: CE val=0.0326, acc val=0.9906
mnist-cnn 9/20: CE val=0.0330, acc val=0.9910
mnist-cnn 10/20: CE val=0.0347, acc val=0.9920
mnist-cnn 11/20: CE val=0.0326, acc val=0.9922
mnist-cnn 12/20: CE val=0.0353, acc val=0.9906
mnist-cnn 13/20: CE val=0.0345, acc val=0.9916
mnist-cnn 14/20: CE val=0.0375, acc val=0.9902

Lectura del experimento. Se ejecutaron 14 épocas; el checkpoint seleccionado por CE de validación corresponde a la época 11, con CE 0.0326. Al cierre, la brecha de exactitud train−val es +0.85 puntos porcentuales. Una brecha describe este conjunto y este presupuesto; no identifica por sí sola su causa. La última época no es el modelo recuperado: prolongar este ensayo no redujo el mínimo de validación.

Pérdida online final: 0.01744126850380139 · pérdida train en eval: 0.004576971201256542

13.6 6. Mapas de activación

Antes de abrir el test inspeccionamos una imagen de validación. Se muestra la salida de la primera convolución antes de ReLU. Cada canal tiene su escala: sirve para comparar patrones dentro del mapa, no magnitudes entre canales. Un mapa luminoso no identifica automáticamente una parte semántica ni explica causalmente la decisión.

modelo.eval()
with torch.inference_mode(): mapas=modelo[0](val.tensors[0][:1].to(DEVICE))[0].cpu().numpy()
fig,axes=plt.subplots(4,4,figsize=(8,8))
for i,ax in enumerate(axes.flat): ax.imshow(mapas[i],cmap='viridis');ax.set_title(f'Canal {i}',fontsize=8);ax.axis('off')
fig.suptitle('Primera convolución · muestra fija de validación');fig.tight_layout();plt.show()

13.7 7. Evaluación final y errores

Cerradas las decisiones, abrimos las 10 000 imágenes de prueba. La matriz se normaliza por clase real y conserva el número de casos anotado. Precision pregunta cuántas predicciones de una clase fueron correctas; recall pregunta cuántos ejemplos reales recuperó. Macro-F1 pesa igual las clases. Un error con softmax alto merece inspección, pero no basta para evaluar calibración ni para diagnosticar su causa.

Una revisión de errores puede generar hipótesis para un trabajo posterior; si se reutiliza este test para rediseñar repetidamente, deja de ser una evaluación final independiente.

resultado=evaluar(modelo,test);diagnostico(resultado,nombres,test.tensors[0].numpy(),'MNIST · prueba oficial')
res_test={'exactitud':resultado['exactitud'],'ce':resultado['ce'],'macro_f1':f1_score(yt,resultado['logits'].argmax(1),average='macro'),'n':len(yt),'mejor_epoca':res['mejor_epoca']}
(ARTEFACTOS/'metricas-prueba.json').write_text(json.dumps(res_test,indent=2),encoding='utf-8')
              precision    recall  f1-score   support

           0     0.9919    0.9939    0.9929       980
           1     0.9939    0.9974    0.9956      1135
           2     0.9846    0.9942    0.9894      1032
           3     0.9931    0.9931    0.9931      1010
           4     0.9959    0.9817    0.9887       982
           5     0.9877    0.9933    0.9905       892
           6     0.9926    0.9864    0.9895       958
           7     0.9893    0.9874    0.9883      1028
           8     0.9928    0.9897    0.9913       974
           9     0.9812    0.9851    0.9832      1009

    accuracy                         0.9903     10000
   macro avg     0.9903    0.9902    0.9902     10000
weighted avg     0.9903    0.9903    0.9903     10000

Decisión después de medir. Exactitud 99.03%; macro-F1 0.9902; 97 errores de 10000. El menor recall corresponde a 4 (98.17%). La confusión más frecuente es 4 → 9 (14 casos). Esto orienta una revisión de etiquetas, cobertura y costes por clase; no autoriza volver a seleccionar con este test.

123

13.8 8. Guardar, reconstruir e inferir

Guardamos pesos y un contrato legible: forma, escala, orden de clases y regla de selección. La arquitectura se reconstruye con crear_cnn; el archivo .pt no la deduce por sí mismo. weights_only=True limita la deserialización a los tipos necesarios del paquete. La comprobación compara logits sobre validación; después se ilustra una inferencia sin ajustar nada.

El valor softmax es probabilidad predicha bajo el modelo; no debe presentarse como garantía de acierto. Una imagen propia, aunque mida 28×28, puede quedar fuera de la distribución de MNIST.

contrato={'arquitectura':'Conv32-valid/pool/Conv64-valid/pool/Linear128/Dropout0.5/Linear10',
          'entrada':'NCHW float32','forma':[1,28,28],'escala':'uint8 / 255',
          'fondo':'oscuro, trazo claro','clases':nombres,'seleccion':'min CE validacion'}
cargado=guardar(modelo,crear_cnn,'mnist-cnn',contrato,val)
j=int(np.random.default_rng(SEED).integers(len(test)))
with torch.inference_mode(): p=cargado(test.tensors[0][j:j+1].to(DEVICE)).softmax(1)[0].cpu().numpy()
fig,axes=plt.subplots(1,2,figsize=(9,3))
axes[0].imshow(xt[j],cmap='gray');axes[0].set_title(f'Real {yt[j]} · predicción {p.argmax()}');axes[0].axis('off')
axes[1].bar(nombres,p);axes[1].set(xlabel='Dígito',ylabel='Probabilidad predicha',ylim=(0,1));fig.tight_layout();plt.show()
Recarga verificada: artefactos_pytorch\mnist-cnn.pt bytes: 903977

13.8.1 Imagen propia desde disco

La función usa PIL, transforma a gris, redimensiona y devuelve NCHW. invertir_fondo=True invierte una entrada de fondo claro y trazo oscuro para aproximarla a MNIST. La inversión no corrige escala del trazo, centrado o recortes; esas decisiones requieren validación con datos externos representativos. Se comprueba la lectura con un PNG de validación guardado por el ejemplo, y se deja una ruta opcional para el archivo del lector.

def cargar_digito(ruta,invertir_fondo=False):
    with Image.open(ruta) as im: a=np.asarray(im.convert('L').resize((28,28),Image.Resampling.BILINEAR),dtype=np.float32)/255
    if invertir_fondo: a=1-a
    return torch.from_numpy(a.copy())[None,None]
ruta_control=ARTEFACTOS/'digito-validacion.png';Image.fromarray(x[iva[0]]).save(ruta_control)
control=cargar_digito(ruta_control)
torch.testing.assert_close(control,val.tensors[0][:1],rtol=0,atol=0)
torch.testing.assert_close(cargar_digito(ruta_control,True),1-control)
with torch.inference_mode(): print('PNG de control · predicción:',cargado(control.to(DEVICE)).argmax(1).item())
ruta_propia=Path('mi_digito_mnist.png')
if ruta_propia.is_file():
    entrada=cargar_digito(ruta_propia,invertir_fondo=True)
    with torch.inference_mode(): print('Archivo propio · probabilidades:',cargado(entrada.to(DEVICE)).softmax(1).cpu().numpy().round(4))
else: print('Ejemplo opcional: coloque un PNG/JPG con fondo claro y trazo oscuro en',ruta_propia.resolve())
PNG de control · predicción: 5
Ejemplo opcional: coloque un PNG/JPG con fondo claro y trazo oscuro en C:\Users\wilde\Dropbox\bit-blog\migracion-pytorch-12-15-2026-10-15\trabajo\13-clasificacion-imagenes-cnn\mi_digito_mnist.png

13.8.2 Lectura de la ejecución registrada

El checkpoint de la época 11 alcanzó 99.03% de exactitud, macro-F1 0.9902 y CE 0.0297 en 10,000 imágenes de prueba. Equivale a 97 clasificaciones incorrectas: un porcentaje alto de aciertos no elimina los errores. La matriz permite localizar su distribución; los ejemplos de mayor probabilidad muestran por qué no debe usarse softmax como una garantía. La siguiente decisión para un uso externo sería medir el cambio de dominio, antes de aumentar la complejidad del modelo.

Cifras de la ejecución CPU del 15 de octubre de 2026; si cambia datos, versión o semilla, vuelva a contrastar esta lectura con las nuevas salidas.

13.9 9. Qué decisión permite este experimento

El checkpoint y el contrato permiten repetir inferencia; las métricas por clase orientan dónde revisar los datos. No hemos probado robustez ante escritura de otra institución, escaneos ni cambios de contraste. Antes de trasladar el sistema, se necesita una muestra externa y un criterio de coste del error.

Para estudiar una mejora, cambie una variable por vez —pooling, dropout o aumento de datos—, seleccione con validación y documente el presupuesto. No reutilice el test como tablero de ajuste.

13.10 Referencias