15  Transferencia y ajuste fino

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

Separaremos tres decisiones: aprender desde cero, reutilizar una base congelada y adaptar sus últimos bloques. La transferencia puede ahorrar datos de ajuste, pero no elimina el coste de la base ni garantiza una mejora. Mediremos las estrategias sobre las mismas fotografías de flores y mantendremos un ejemplo pequeño de clasificación RGB.

15.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}

15.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.

15.2 2. Leer curvas antes de intervenir

Los siguientes trazados son simulados: muestran hipótesis, no modelos entrenados. Si ambas pérdidas siguen descendiendo al llegar al límite, el presupuesto puede ser insuficiente. Si train mejora y validación empeora, existe una divergencia que requiere investigar datos, regularización y variabilidad. Si ambas se estancan altas, revise primero el pipeline, las etiquetas y la tasa; aumentar capacidad no es la única respuesta.

El diagnóstico orienta el siguiente experimento; una curva no demuestra una causa. El mínimo de validación identifica un checkpoint de este ensayo, no el rendimiento futuro de una nueva distribución.

fig,axes=plt.subplots(1,3,figsize=(12,3.4))
casos=[('Descenso',[1.2,.8,.55,.4,.32],[1.15,.78,.58,.47,.44]),('Divergencia',[1.1,.6,.35,.18,.09],[1.05,.7,.62,.75,.92]),('Estancamiento',[1.8,1.7,1.65,1.62,1.6],[1.82,1.74,1.7,1.68,1.67])]
for ax,(t,a,b) in zip(axes,casos): ax.plot(range(1,6),a,'-o',label='Train');ax.plot(range(1,6),b,'-o',label='Val');ax.set(xlabel='Época',ylabel='CE simulada',title=t);ax.legend()
fig.suptitle('Esquemas conceptuales · no son resultados');fig.tight_layout();plt.show()

15.3 3. Un problema RGB pequeño: proxy con CIFAR-10

Conservamos las clases avión, automóvil, pájaro y gato, las 5 000 imágenes de entrenamiento, 1 000 de validación y 2 000 de prueba del protocolo histórico. Se filtran las primeras cuatro clases y se permuta el desarrollo con semilla 42 antes de tomar los recortes. No son fotografías de empaques: una eventual aplicación a control de productos necesita datos y etiquetas de ese dominio.

La CNN usa filtros 32 y 64, pooling, 64 unidades densas y dropout 0,3. Conservamos seis épocas como máximo, lote 64, Adam 0,001 y paciencia 2. Es un ejercicio para leer errores y costes por clase, no evidencia de aptitud industrial.

import pickle
cache=Path.home()/'.keras'/'datasets'/'cifar-10-batches-py-target'/'cifar-10-batches-py'
if cache.is_dir():
    def leer_cifar(p):
        with p.open('rb') as f: d=pickle.load(f,encoding='bytes')
        return d[b'data'].reshape(-1,3,32,32),np.asarray(d[b'labels'])
    lotes=[leer_cifar(cache/f'data_batch_{i}') for i in range(1,6)]
    xc=np.concatenate([a for a,b in lotes]);yc=np.concatenate([b for a,b in lotes]);xct,yct=leer_cifar(cache/'test_batch')
else:
    from torchvision.datasets import CIFAR10
    a=CIFAR10(str(Path.home()/'.cache'/'bit-datasets'),train=True,download=True);b=CIFAR10(a.root,train=False,download=True)
    xc=np.moveaxis(a.data,-1,1);yc=np.array(a.targets);xct=np.moveaxis(b.data,-1,1);yct=np.array(b.targets)
dev=np.flatnonzero(yc<4);dev=dev[np.random.default_rng(SEED).permutation(len(dev))]
itr=dev[:5000];iva=dev[5000:6000];ite=np.flatnonzero(yct<4)[:2000]
trq=conjunto(xc[itr].astype(np.float32)/255,yc[itr]);vaq=conjunto(xc[iva].astype(np.float32)/255,yc[iva]);teq=conjunto(xct[ite].astype(np.float32)/255,yct[ite])
np.savez_compressed(ARTEFACTOS/'cifar-particiones.npz',train=itr,val=iva,test=ite)
def crear_proxy(): return nn.Sequential(nn.Conv2d(3,32,3,padding=1),nn.ReLU(),nn.MaxPool2d(2),nn.Conv2d(32,64,3,padding=1),nn.ReLU(),nn.MaxPool2d(2),nn.Flatten(),nn.Linear(64*8*8,64),nn.ReLU(),nn.Dropout(.3),nn.Linear(64,4))
semilla();proxy=inicializar(crear_proxy());hq,rq=entrenar(proxy,trq,vaq,6,lote=64,paciencia=2,nombre='cifar-proxy')
curvas(hq,'CIFAR-10 · cuatro clases',rq['mejor_epoca']);lectura(hq,rq)
resultado_q=evaluar(proxy,teq);diagnostico(resultado_q,['Avión','Automóvil','Pájaro','Gato'],teq.tensors[0].numpy(),'CIFAR proxy')
cifar-proxy 1/6: CE val=0.9845, acc val=0.6170
cifar-proxy 2/6: CE val=0.8328, acc val=0.6620
cifar-proxy 3/6: CE val=0.8236, acc val=0.6770
cifar-proxy 4/6: CE val=0.7574, acc val=0.6950
cifar-proxy 5/6: CE val=0.7569, acc val=0.7080
cifar-proxy 6/6: CE val=0.6726, acc val=0.7310

Lectura del experimento. Se ejecutaron 6 épocas; el checkpoint seleccionado por CE de validación corresponde a la época 6, con CE 0.6726. Al cierre, la brecha de exactitud train−val es +4.28 puntos porcentuales. Una brecha describe este conjunto y este presupuesto; no identifica por sí sola su causa. El mínimo está en el límite del presupuesto; no demuestra que más épocas mejorarían el resultado.

              precision    recall  f1-score   support

       Avión     0.7702    0.7418    0.7557       488
   Automóvil     0.9134    0.8373    0.8737       504
      Pájaro     0.6143    0.6191    0.6167       512
        Gato     0.6540    0.7278    0.6889       496

    accuracy                         0.7310      2000
   macro avg     0.7380    0.7315    0.7338      2000
weighted avg     0.7376    0.7310    0.7333      2000

Decisión después de medir. Exactitud 73.10%; macro-F1 0.7338; 538 errores de 2000. El menor recall corresponde a Pájaro (61.91%). La confusión más frecuente es Pájaro → Gato (132 casos). Esto orienta una revisión de etiquetas, cobertura y costes por clase; no autoriza volver a seleccionar con este test.

array([[362,  24,  76,  26],
       [ 40, 422,   9,  33],
       [ 56,   7, 317, 132],
       [ 12,   9, 114, 361]])

15.4 4. Fotografías de flores: datos y trazabilidad

El experimento principal usa Flower Photos, cinco clases y 3 670 fotografías. Se ordenan rutas y clases para que el manifiesto sea reproducible. El original no ordenaba los archivos: ese cambio puede modificar la partición respecto de sus salidas antiguas. Conservamos las proporciones y la semilla: 20 % prueba y, del resto, 20 % validación estratificada (aproximadamente 64/16/20).

Guardamos rutas relativas, etiquetas, partición y SHA-256 por archivo. La división por imagen no garantiza independencia por planta, fotógrafo o sesión: si existen imágenes relacionadas, la generalización a nuevos contextos necesita un agrupamiento adecuado. El manifiesto hace la decisión auditable; no elimina esa limitación.

from torchvision import models, transforms
import torchvision
from torchvision.datasets.utils import download_and_extract_archive
raiz=Path.home()/'.keras'/'datasets'/'flower_photos'
if (raiz/'flower_photos').is_dir(): raiz=raiz/'flower_photos'
if not (raiz/'daisy').is_dir():
    destino=Path.home()/'.cache'/'bit-datasets'
    download_and_extract_archive('https://storage.googleapis.com/download.tensorflow.org/example_images/flower_photos.tgz',str(destino))
    raiz=destino/'flower_photos'
clases=sorted(p.name for p in raiz.iterdir() if p.is_dir())
rutas=[];yf=[]
for i,clase in enumerate(clases):
    archivos=sorted(p for p in (raiz/clase).iterdir() if p.suffix.lower() in ['.jpg','.jpeg','.png'])
    rutas.extend(archivos);yf.extend([i]*len(archivos))
yf=np.array(yf);indices=np.arange(len(yf))
dev,ite=train_test_split(indices,test_size=.2,stratify=yf,random_state=SEED)
itr,iva=train_test_split(dev,test_size=.2,stratify=yf[dev],random_state=SEED)
assert not(set(itr)&set(iva) or set(itr)&set(ite) or set(iva)&set(ite))
particion=np.full(len(yf),'train',dtype='<U5');particion[iva]='val';particion[ite]='test'
manifiesto=pd.DataFrame({'ruta':[str(p.relative_to(raiz)) for p in rutas],'clase':[clases[i] for i in yf],'etiqueta':yf,'particion':particion,'sha256':[hashlib.sha256(p.read_bytes()).hexdigest() for p in rutas]})
manifiesto.to_csv(ARTEFACTOS/'flowers-manifiesto.csv',index=False)
display(pd.crosstab(manifiesto.clase,manifiesto.particion));print('Torchvision:',torchvision.__version__)
fig,axes=plt.subplots(2,4,figsize=(11,5))
for ax,j in zip(axes.flat,itr[:8]):
    with Image.open(rutas[j]) as im: ax.imshow(im.convert('RGB'))
    ax.set_title(clases[yf[j]],fontsize=9);ax.axis('off')
fig.suptitle('Flower Photos · muestra de entrenamiento');fig.tight_layout();plt.show()
particion test train val
clase
daisy 127 405 101
dandelion 179 575 144
roses 128 410 103
sunflowers 140 447 112
tulips 160 511 128
Torchvision: 0.29.1+cpu

15.5 5. Contrato de preprocesamiento del modelo preentrenado

Los pesos MobileNet_V2_Weights.IMAGENET1K_V2 de torchvision esperan canales RGB y normalización ImageNet: media [0.485,0.456,0.406] y desviación [0.229,0.224,0.225]. No reutilizamos la escala [−1,1] del modelo de otra biblioteca. El enum fija una versión concreta de pesos.

Para conservar el presupuesto docente usamos la transformación del enum con tamaño de recorte 128 y redimensionamiento del lado corto 146; se mantiene relación de aspecto y se hace recorte central. Es una adaptación explícita del preset, que por defecto usa otro tamaño: no prometemos reproducir su puntuación ImageNet. La CNN desde cero recibe exactamente la misma geometría, con escala [0,1]. No añadimos aumento aleatorio en esta comparación.

Decodificamos una vez y almacenamos uint8 de 128×128 para evitar repetir lectura de fotografías. En un conjunto mayor convendría lectura por lotes y caché acotada. Este coste de preparación se informa por separado.

PESOS=models.MobileNet_V2_Weights.IMAGENET1K_V2
preset=PESOS.transforms(crop_size=128,resize_size=146)
geometria=transforms.Compose([transforms.Resize(146,interpolation=transforms.InterpolationMode.BILINEAR),transforms.CenterCrop(128),transforms.PILToTensor()])
inicio=time.perf_counter();imagenes=[]
for p in rutas:
    with Image.open(p) as im: imagenes.append(geometria(im.convert('RGB')).numpy())
imagenes=np.stack(imagenes);preparacion_s=time.perf_counter()-inicio
MEDIA=torch.tensor(preset.mean)[:,None,None];DESV=torch.tensor(preset.std)[:,None,None]
class Flores(Dataset):
    def __init__(self,indices,normalizar): self.indices=np.array(indices);self.normalizar=normalizar
    def __len__(self): return len(self.indices)
    def __getitem__(self,i):
        j=self.indices[i];x=torch.from_numpy(imagenes[j].copy()).float()/255
        if self.normalizar: x=(x-MEDIA)/DESV
        return x,torch.tensor(yf[j],dtype=torch.long)
tra,vla,tea=[Flores(i,False) for i in [itr,iva,ite]]
trm,vlm,tem=[Flores(i,True) for i in [itr,iva,ite]]
with Image.open(rutas[0]) as im: referencia=preset(im.convert('RGB'))
torch.testing.assert_close(Flores([0],True)[0][0],referencia,rtol=1e-5,atol=1e-6)
print('Preparación:',round(preparacion_s,2),'s · caché uint8:',round(imagenes.nbytes/2**20,1),'MiB')
contrato={'pesos':'MobileNet_V2_Weights.IMAGENET1K_V2','entrada':'NCHW RGB float32','forma':[3,128,128],'resize_lado_corto':146,'recorte_central':128,'media':preset.mean,'desviacion':preset.std,'clases':clases,'seleccion':'min CE validacion','preparacion_s':preparacion_s}
(ARTEFACTOS/'preprocesamiento.json').write_text(json.dumps(contrato,indent=2),encoding='utf-8')
Preparación: 15.2 s · caché uint8: 172.0 MiB
453

15.6 6. Estrategia A: aprender desde cero

La CNN mantiene tres convoluciones (32,64,64), pooling tras cada una, promedio global, 128 unidades y dropout 0,3. Se entrenan todos los pesos, hasta seis épocas, lote 32 y tasa 0,001. Es un baseline deliberadamente pequeño; no es una MobileNet inicializada al azar. Por tanto la comparación con transferencia combina arquitectura y preentrenamiento, y no aísla el efecto de ImageNet.

def crear_cero():
    return nn.Sequential(nn.Conv2d(3,32,3,padding=1),nn.ReLU(),nn.MaxPool2d(2),nn.Conv2d(32,64,3,padding=1),nn.ReLU(),nn.MaxPool2d(2),nn.Conv2d(64,64,3,padding=1),nn.ReLU(),nn.MaxPool2d(2),nn.AdaptiveAvgPool2d(1),nn.Flatten(),nn.Linear(64,128),nn.ReLU(),nn.Dropout(.3),nn.Linear(128,5))
semilla();a=inicializar(crear_cero());ha,ra=entrenar(a,tra,vla,6,lote=32,paciencia=3,nombre='A-cero')
curvas(ha,'A · desde cero',ra['mejor_epoca']);lectura(ha,ra)
A-cero 1/6: CE val=1.2282, acc val=0.5034
A-cero 2/6: CE val=1.1539, acc val=0.5000
A-cero 3/6: CE val=1.0874, acc val=0.5918
A-cero 4/6: CE val=1.0469, acc val=0.5918
A-cero 5/6: CE val=0.9881, acc val=0.6105
A-cero 6/6: CE val=0.9237, acc val=0.6599

Lectura del experimento. Se ejecutaron 6 épocas; el checkpoint seleccionado por CE de validación corresponde a la época 6, con CE 0.9237. Al cierre, la brecha de exactitud train−val es +0.50 puntos porcentuales. Una brecha describe este conjunto y este presupuesto; no identifica por sí sola su causa. El mínimo está en el límite del presupuesto; no demuestra que más épocas mejorarían el resultado.

15.7 7. Estrategia B: base congelada

Se descargan pesos reales de ImageNet si no están en caché. Reemplazamos el clasificador por dropout 0,2 y una capa lineal de cinco salidas. requires_grad=False impide gradientes en la base; también mantenemos features.eval() para evitar cambios en los buffers de BatchNorm. Congelar parámetros y congelar estadísticas son decisiones diferentes.

Como no hay aumento aleatorio ni ajuste de la base, extraemos características una vez para train y validación. Entrenar la cabeza con esa caché es equivalente a repetir el forward de la base en eval con la misma entrada. Comprobamos logits entre la versión completa y la versión con caché. El tiempo de extracción se suma al tiempo de B; su test se procesa después de seleccionar estrategias. La caché dejaría de representar el experimento si cambiara el aumento, el preprocesamiento o cualquier peso de la base.

semilla();inicio=time.perf_counter()
b=models.mobilenet_v2(weights=PESOS).to(DEVICE)
b.classifier=nn.Sequential(nn.Dropout(.2),nn.Linear(1280,5)).to(DEVICE)
nn.init.xavier_uniform_(b.classifier[1].weight);nn.init.zeros_(b.classifier[1].bias)
for p in b.features.parameters(): p.requires_grad=False
b.features.eval()
descarga_construccion_s=time.perf_counter()-inicio
estado_base={k:v.cpu().clone() for k,v in b.features.state_dict().items()}
@torch.inference_mode()
def extraer(modelo,ds):
    modelo.eval();xx=[];yy=[]
    for x,y in cargador(ds,32): xx.append(modelo.features(x.to(DEVICE)).mean((2,3)).cpu());yy.append(y)
    return TensorDataset(torch.cat(xx),torch.cat(yy))
inicio=time.perf_counter();trf=extraer(b,trm);vlf=extraer(b,vlm);extraccion_s=time.perf_counter()-inicio
hb,rb=entrenar(b.classifier,trf,vlf,5,lote=32,paciencia=3,nombre='B-congelada')
b.eval()
with torch.inference_mode():
    xb,yb=next(iter(cargador(vlm,16)))
    torch.testing.assert_close(b(xb.to(DEVICE)),b.classifier(vlf.tensors[0][:16].to(DEVICE)),rtol=1e-4,atol=1e-5)
assert all(torch.equal(v.cpu(),estado_base[k]) for k,v in b.features.state_dict().items())
rb['segundos_cabeza']=rb['segundos'];rb['segundos']+=extraccion_s
rb['extraccion_s']=extraccion_s;rb['parametros']=parametros(b);rb['entrenables']=parametros(b.classifier)
print('Base congelada: pesos Y buffers intactos · equivalencia de logits comprobada')
curvas(hb,'B · cabeza con base fija',rb['mejor_epoca']);lectura(hb,rb)
B-congelada 1/5: CE val=0.5571, acc val=0.8299
B-congelada 2/5: CE val=0.4724, acc val=0.8605
B-congelada 3/5: CE val=0.4266, acc val=0.8486
B-congelada 4/5: CE val=0.4025, acc val=0.8622
B-congelada 5/5: CE val=0.3987, acc val=0.8724
Base congelada: pesos Y buffers intactos · equivalencia de logits comprobada

Lectura del experimento. Se ejecutaron 5 épocas; el checkpoint seleccionado por CE de validación corresponde a la época 5, con CE 0.3987. Al cierre, la brecha de exactitud train−val es +7.52 puntos porcentuales. Una brecha describe este conjunto y este presupuesto; no identifica por sí sola su causa. El mínimo está en el límite del presupuesto; no demuestra que más épocas mejorarían el resultado.

15.8 8. Estrategia C: ajuste fino con límites explícitos

C parte del checkpoint seleccionado de B, incluida la cabeza. Se descongelan los tres últimos bloques de features (índices 16,17,18 en esta implementación), manteniendo todas las capas BatchNorm en eval y sus parámetros fijos. No traducimos “últimas 30 capas” de otra biblioteca a 30 módulos de PyTorch: las unidades de conteo son diferentes. Imprimimos los nombres entrenables.

Creamos un Adam nuevo sólo con los parámetros habilitados y tasa 0,00001, hasta cuatro épocas y paciencia 3. La función restablece BatchNorm.eval después de cada train(). El checkpoint de inicio B participa como época 0: si C no reduce la CE de validación, se conserva B. Esta regla se fija antes de abrir prueba.

El optimizador nuevo no hereda los momentos de la fase anterior; es un cambio deliberado. El coste de obtener C incluye extracción y entrenamiento de B más su propia fase, aunque sólo algunos parámetros reciban gradientes.

c_model=copy.deepcopy(b)
for p in c_model.parameters(): p.requires_grad=False
indices_abiertos=list(range(len(c_model.features)-3,len(c_model.features)))
for j in indices_abiertos:
    for p in c_model.features[j].parameters(): p.requires_grad=True
for p in c_model.classifier.parameters(): p.requires_grad=True
for mod in c_model.modules():
    if isinstance(mod,nn.modules.batchnorm._BatchNorm):
        mod.eval()
        for p in mod.parameters(): p.requires_grad=False
def fijar_bn(m):
    for mod in m.modules():
        if isinstance(mod,nn.modules.batchnorm._BatchNorm): mod.eval()
nombres_entrenables=[n for n,p in c_model.named_parameters() if p.requires_grad]
antes={k:v.cpu().clone() for k,v in c_model.state_dict().items()}
print('Bloques abiertos:',indices_abiertos);print('Parámetros habilitados:',nombres_entrenables)
hc,rc=entrenar(c_model,trm,vlm,4,lote=32,lr=1e-5,paciencia=3,nombre='C-ajuste-fino',modo_train=fijar_bn,incluir_inicial=True)
permitidos=set(nombres_entrenables)
cambiados=[k for k,v in c_model.state_dict().items() if not torch.equal(v.cpu(),antes[k])]
assert all(k in permitidos for k in cambiados), 'Cambió un parámetro o buffer congelado'
assert all(not m.training for m in c_model.modules() if isinstance(m,nn.modules.batchnorm._BatchNorm))
print('Cambios del checkpoint:',len(cambiados),'· sólo parámetros habilitados; BN intacta')
rc['segundos_fase_C']=rc['segundos'];rc['segundos']+=rb['segundos']
curvas(hc,'C · adaptación desde B (época 0 elegible)',rc['mejor_epoca']);lectura(hc,rc)
(ARTEFACTOS/'contrato-congelacion.json').write_text(json.dumps({'B_base_intacta':True,'C_bloques':indices_abiertos,'C_entrenables':nombres_entrenables,'C_cambiados_checkpoint':cambiados,'BN_buffers_intactos':True,'descarga_construccion_s':descarga_construccion_s},indent=2),encoding='utf-8')
Bloques abiertos: [16, 17, 18]
Parámetros habilitados: ['features.16.conv.0.0.weight', 'features.16.conv.1.0.weight', 'features.16.conv.2.weight', 'features.17.conv.0.0.weight', 'features.17.conv.1.0.weight', 'features.17.conv.2.weight', 'features.18.0.weight', 'classifier.1.weight', 'classifier.1.bias']
C-ajuste-fino 1/4: CE val=0.3907, acc val=0.8759
C-ajuste-fino 2/4: CE val=0.4014, acc val=0.8724
C-ajuste-fino 3/4: CE val=0.3888, acc val=0.8673
C-ajuste-fino 4/4: CE val=0.3847, acc val=0.8776
Cambios del checkpoint: 9 · sólo parámetros habilitados; BN intacta

Lectura del experimento. Se ejecutaron 4 épocas; el checkpoint seleccionado por CE de validación corresponde a la época 4, con CE 0.3847. Al cierre, la brecha de exactitud train−val es +9.82 puntos porcentuales. Una brecha describe este conjunto y este presupuesto; no identifica por sí sola su causa. El mínimo está en el límite del presupuesto; no demuestra que más épocas mejorarían el resultado.

785

15.9 9. Selección, prueba y decisiones

Se selecciona A, B o C por CE de validación y sólo entonces se calculan los tres resultados de prueba. La regla no se cambia si el orden por exactitud de test difiere. Si B y C empatan en CE porque C conserva la época 0, el orden prefijado conserva B, que evita el coste adicional.

La tabla distingue parámetros totales de entrenables. Congelar una base no la elimina de memoria ni del forward; por eso exactitud dividida por parámetros entrenables no mide eficiencia de inferencia. Los tiempos excluyen descarga, decodificación y evaluación de test; B incluye extracción y C incluye obtener B. No equivalen a latencia de producción ni a un coste de preentrenamiento de ImageNet.

Los archivos *-entrenamiento.json describen la fase que ejecutó el motor (en B, sólo la cabeza). comparacion.csv describe las estrategias completas: incluye la base en los parámetros totales de B y añade los costes previos al tiempo acumulado de C. No confunda el tamaño de la cabeza entrenada con el tamaño del modelo que se despliega.

modelos={'A-cero':a,'B-congelada':b,'C-ajuste-fino':c_model};resumenes={'A-cero':ra,'B-congelada':rb,'C-ajuste-fino':rc}
ganador=min(resumenes,key=lambda k:resumenes[k]['ce_val']);print('Seleccionado antes de test:',ganador)
resultados={};filas=[]
for nombre,m in modelos.items():
    rr=evaluar(m,tea if nombre=='A-cero' else tem,lote=32);resultados[nombre]=rr
    filas.append({**resumenes[nombre],'ce_test':rr['ce'],'acc_test':rr['exactitud'],'macro_f1':f1_score(rr['y'],rr['logits'].argmax(1),average='macro')})
tabla=pd.DataFrame(filas);display(tabla);tabla.to_csv(ARTEFACTOS/'comparacion.csv',index=False)
fig,axes=plt.subplots(1,3,figsize=(12,4))
for ax,col,t in zip(axes,['ce_val','acc_test','segundos'],['CE validación (selección)','Exactitud prueba (informe)','Tiempo de ajuste acumulado, s']): ax.bar(tabla.modelo,tabla[col]);ax.set_title(t,fontsize=10);ax.tick_params(axis='x',rotation=25)
fig.tight_layout();plt.show()
seleccionado=resultados[ganador];diagnostico(seleccionado,clases,imagenes[ite].astype(np.float32)/255,ganador+' · Flowers')
display(Markdown(f'**Resultado y alcance.** Se eligió **{ganador}** por CE de validación. Su exactitud de prueba es {seleccionado["exactitud"]:.2%}, frente a una referencia uniforme de 20 % para cinco clases. La referencia por clase mayoritaria puede ser distinta. Las diferencias entre A y B combinan arquitectura, preentrenamiento y escala de entrada; B frente a C estudia continuar el ajuste desde el mismo punto. Un ensayo no basta para recomendar esta estrategia para cualquier dominio.'))
Seleccionado antes de test: C-ajuste-fino
modelo mejor_epoca epocas ce_val segundos parametros entrenables ce_test acc_test macro_f1 segundos_cabeza extraccion_s segundos_fase_C
0 A-cero 6 6 0.923705 512.893266 65285 65285 0.924013 0.670300 0.661454 NaN NaN NaN
1 B-congelada 5 5 0.398659 26.729286 2230277 6405 0.350500 0.866485 0.864983 1.164475 25.564812 NaN
2 C-ajuste-fino 4 4 0.384659 214.051559 2230277 1201285 0.319832 0.884196 0.883037 NaN NaN 187.322272

              precision    recall  f1-score   support

       daisy     0.8828    0.8898    0.8863       127
   dandelion     0.9257    0.9050    0.9153       179
       roses     0.8636    0.8906    0.8769       128
  sunflowers     0.8611    0.8857    0.8732       140
      tulips     0.8774    0.8500    0.8635       160

    accuracy                         0.8842       734
   macro avg     0.8821    0.8842    0.8830       734
weighted avg     0.8846    0.8842    0.8843       734

Decisión después de medir. Exactitud 88.42%; macro-F1 0.8830; 85 errores de 734. El menor recall corresponde a tulips (85.00%). La confusión más frecuente es tulips → roses (14 casos). Esto orienta una revisión de etiquetas, cobertura y costes por clase; no autoriza volver a seleccionar con este test.

Resultado y alcance. Se eligió C-ajuste-fino por CE de validación. Su exactitud de prueba es 88.42%, frente a una referencia uniforme de 20 % para cinco clases. La referencia por clase mayoritaria puede ser distinta. Las diferencias entre A y B combinan arquitectura, preentrenamiento y escala de entrada; B frente a C estudia continuar el ajuste desde el mismo punto. Un ensayo no basta para recomendar esta estrategia para cualquier dominio.

15.9.1 ¿Cambian los errores al transferir?

Los modelos se evalúan en el mismo orden del manifiesto: comprobamos igualdad de etiquetas, no sólo cantidad. La intersección de errores revela coincidencias y diferencias, pero una intersección grande no demuestra que “el problema sean los datos”. Harían falta revisión de etiquetas, ejemplos, incertidumbre y otros modelos. No atribuimos un error a fondo o color sólo por observar una miniatura.

assert all(np.array_equal(r['y'],yf[ite]) for r in resultados.values())
ea=resultados['A-cero']['logits'].argmax(1)!=yf[ite]
eb=resultados['B-congelada']['logits'].argmax(1)!=yf[ite]
display(pd.DataFrame({'categoria':['Fallan ambos','Sólo A','Sólo B','Acertaron ambos'],'casos':[(ea&eb).sum(),(ea&~eb).sum(),(~ea&eb).sum(),(~ea&~eb).sum()]}))
fig,ax=plt.subplots(figsize=(7,3.5));ax.plot(hb.epoca,hb.ce_val,'-o',label='B · cabeza')
ax.plot(len(hb)+hc.epoca,hc.ce_val,'-o',label='C · ajuste fino');ax.axvline(len(hb)+.5,color='.4',ls='--')
ax.set(xlabel='Época de cada fase colocada en secuencia',ylabel='CE validación',title='C inicia desde el mejor checkpoint B, no necesariamente desde su última época');ax.legend();fig.tight_layout();plt.show()
categoria casos
0 Fallan ambos 72
1 Sólo A 170
2 Sólo B 26
3 Acertaron ambos 466

15.10 10. Bloques residuales y otras bases

MobileNetV2 combina convolución separable en profundidad, expansión y cuellos de botella lineales; usa atajos cuando forma y stride lo permiten. Una suma residual exige dimensiones compatibles. El bloque mínimo siguiente ilustra la suma, pero no reproduce un bloque MobileNetV2.

ResNet50 se carga también con pesos ImageNet reales para inspeccionar la base, su salida y tamaño, sin entrenarla ni competir en la tabla. Que esté preentrenada no demuestra que supere a MobileNet en flores: eso requeriría otro ensayo con selección propia y presupuesto comparable.

class ResidualMinimo(nn.Module):
    def __init__(self): super().__init__();self.a=nn.Conv2d(16,16,3,padding=1);self.b=nn.Conv2d(16,16,3,padding=1)
    def forward(self,x): return F.relu(x+self.b(F.relu(self.a(x))))
mini=ResidualMinimo();assert mini(torch.zeros(2,16,16,16)).shape==(2,16,16,16)
resnet=models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V2).to(DEVICE).eval()
total_resnet=parametros(resnet);resnet.fc=nn.Identity()
with torch.inference_mode(): forma=tuple(resnet(torch.zeros(1,3,128,128,device=DEVICE)).shape)
print({'ResNet50_parametros_con_cabeza_ImageNet':total_resnet,'parametros_base':parametros(resnet),'salida_base':forma,'entrenamiento_en_este_capitulo':False})
del resnet
{'ResNet50_parametros_con_cabeza_ImageNet': 25557032, 'parametros_base': 23508032, 'salida_base': (1, 2048), 'entrenamiento_en_este_capitulo': False}

15.11 11. Guardar el modelo con su contrato

Para transferidos, la reconstrucción usa weights=None: el archivo contiene todos los pesos de la base y de la cabeza, y la inferencia no necesita descargar ImageNet otra vez. Se registra qué estrategia ganó y cómo transformar RGB. Para A se conserva la geometría y se elimina la normalización ImageNet.

Si el destino fueran productos o imágenes clínicas, habría que verificar cobertura, grupos relacionados, costes de falsos positivos y negativos, y calibración. Estas exigencias dependen de la aplicación; el ejercicio de flores no las valida.

def crear_transfer_sin_descarga():
    m=models.mobilenet_v2(weights=None);m.classifier=nn.Sequential(nn.Dropout(.2),nn.Linear(1280,5));return m
contrato_final=dict(contrato,estrategia=ganador)
if ganador=='A-cero': contrato_final.update(pesos='desde cero',media=None,desviacion=None,escala='uint8 /255')
recargado=guardar(modelos[ganador],crear_cero if ganador=='A-cero' else crear_transfer_sin_descarga,'flowers-seleccionado',contrato_final,vla if ganador=='A-cero' else vlm)
Recarga verificada: artefactos_pytorch\flowers-seleccionado.pt bytes: 9157323

15.11.1 Lectura de la ejecución registrada

La regla prefijada eligió C-ajuste-fino. En prueba se observó 67.03% para A, 86.65% para B y 88.42% para C. La CE de validación pasó de 0.3987 en B a 0.3847 en C; el checkpoint de C corresponde a la época 4. La época 0 significa conservar B, no un fallo oculto del entrenamiento. La tabla de costes incluye obtener B antes de C; una cabeza con pocos parámetros entrenables no hace desaparecer la base de la inferencia. Estas cifras justifican la estrategia de este ensayo bajo su regla de validación, no una recomendación universal de transferencia.

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.

15.12 12. Cómo continuar con evidencia

  • Si A valida peor, pruebe si más datos o un presupuesto comparable cambian el resultado; esta comparación no aisló el preentrenamiento.
  • Si C no mejora B, conserve B y pruebe en un estudio posterior otra tasa o apertura de bloques, seleccionando con validación. No diagnostique olvido catastrófico sólo por ese resultado.
  • Si una clase tiene recall bajo, revise cobertura y confusiones; si precision es baja, revise a qué otras clases está atrayendo. La prioridad depende del coste de cada error.
  • Para medir eficiencia, registre tamaño completo, memoria, tiempo de extracción, ajuste y latencia con un protocolo declarado; no use sólo parámetros que recibieron gradiente.

15.13 Referencias