Visión moderna: modelos preentrenados, autoaprendizaje y CLIP

Tema 26 · Fase 6, Ver, oír y crear · Toda la ruta

Los 16 bloques de código de la teoría, en el mismo orden. Cópialos y córrelos en tu Python o en Colab; en la ficha Teoría está la explicación de cada uno.

Los datos: la Tierra vista desde un satélite

import numpy as np
import matplotlib.pyplot as plt
import torch
from torch import nn
from torchvision.datasets import EuroSAT

torch.manual_seed(0)
device = "cuda" if torch.cuda.is_available() else "cpu"

# La primera vez descarga 94 MB a la carpeta datos/
eurosat = EuroSAT(root="datos", download=True)
print("Fotos:", len(eurosat), "  clases:", len(eurosat.classes))
print(eurosat.classes[:5])
print(eurosat.classes[5:])

CLASES = ["Forest", "Highway", "Residential", "River"]
NOMBRES = ["bosque", "autopista", "residencial", "río"]
etiquetas = np.array(eurosat.targets)
rng = np.random.default_rng(42)
idx_ent, idx_val = [], []
for clase in CLASES:
    de_la_clase = np.flatnonzero(etiquetas == eurosat.class_to_idx[clase])
    elegidas = rng.choice(de_la_clase, size=200, replace=False)
    idx_ent += list(elegidas[:100])
    idx_val += list(elegidas[100:])

# Fotos PIL de 64 × 64, ordenadas por clase: 100 de cada una
fotos_ent = [eurosat[i][0] for i in idx_ent]
fotos_val = [eurosat[i][0] for i in idx_val]
y_ent = torch.arange(4).repeat_interleave(100)
y_val = torch.arange(4).repeat_interleave(100)
print("Entrenamiento:", len(fotos_ent), "  validación:", len(fotos_val))

fig, ejes = plt.subplots(4, 4, figsize=(4.8, 4.8))
for fila in range(4):
    for col in range(4):
        eje = ejes[fila, col]
        eje.imshow(fotos_ent[fila * 100 + col])
        eje.set_xticks([])
        eje.set_yticks([])
        eje.spines[:].set_visible(False)
    ejes[fila, 0].set_ylabel(NOMBRES[fila])
fig.suptitle("Cuatro clases de EuroSAT")
plt.show()

Redes que ya vieron millones de fotos

from torchvision.models import resnet18, ResNet18_Weights

# Pesos de ImageNet: la primera vez descarga 45 MB
pesos = ResNet18_Weights.DEFAULT
red = resnet18(weights=pesos)
total = sum(p.numel() for p in red.parameters())
print(f"Parámetros: {total:,}".replace(",", " "))
print("Última capa:", red.fc)

preparar = pesos.transforms()
print(preparar)

x_ent = torch.stack([preparar(f) for f in fotos_ent])
x_val = torch.stack([preparar(f) for f in fotos_val])
print("Tensores:", tuple(x_ent.shape), tuple(x_val.shape))
import torch
from torch import nn


def evaluar(modelo, cargador, funcion_perdida, dispositivo="cpu"):
    """Pérdida media y exactitud de `modelo` sobre `cargador`."""
    modelo.eval()
    suma, aciertos, total = 0.0, 0, 0
    with torch.no_grad():
        for x, y in cargador:
            x, y = x.to(dispositivo), y.to(dispositivo)
            salida = modelo(x)
            suma += funcion_perdida(salida, y).item() * len(y)
            aciertos += (salida.argmax(dim=1) == y).sum().item()
            total += len(y)
    return suma / total, aciertos / total


def entrenar(modelo, cargador_ent, cargador_val, epocas, optimizador,
             funcion_perdida=None, planificador=None, dispositivo="cpu",
             mostrar=True):
    """Entrena `modelo` y devuelve el historial de cada época."""
    if funcion_perdida is None:
        funcion_perdida = nn.CrossEntropyLoss()
    modelo.to(dispositivo)
    historial = {"perdida_ent": [], "exactitud_ent": [],
                 "perdida_val": [], "exactitud_val": []}
    for epoca in range(1, epocas + 1):
        modelo.train()
        suma, aciertos, total = 0.0, 0, 0
        for x, y in cargador_ent:
            x, y = x.to(dispositivo), y.to(dispositivo)
            optimizador.zero_grad()
            salida = modelo(x)
            perdida = funcion_perdida(salida, y)
            perdida.backward()
            optimizador.step()
            suma += perdida.item() * len(y)
            aciertos += (salida.argmax(dim=1) == y).sum().item()
            total += len(y)
        if planificador is not None:
            planificador.step()
        perdida_val, exactitud_val = evaluar(
            modelo, cargador_val, funcion_perdida, dispositivo)
        historial["perdida_ent"].append(suma / total)
        historial["exactitud_ent"].append(aciertos / total)
        historial["perdida_val"].append(perdida_val)
        historial["exactitud_val"].append(exactitud_val)
        if mostrar:
            print(f"Época {epoca}: pérdida {suma / total:.3f}, "
                  f"validación {perdida_val:.3f}, "
                  f"exactitud de validación {exactitud_val:.3f}")
    return historial
from torch.utils.data import DataLoader, TensorDataset

# Sin la cabeza de ImageNet, la red devuelve 512 números por foto
red.fc = nn.Identity()
red = red.to(device).eval()


def variables(red, x, lote=100):
    """Pasa x por la red congelada, de a `lote` fotos."""
    partes = []
    with torch.no_grad():
        for i in range(0, len(x), lote):
            partes.append(red(x[i:i + lote].to(device)).cpu())
    return torch.cat(partes)


v_ent = variables(red, x_ent)
v_val = variables(red, x_val)
print("Variables:", tuple(v_ent.shape), tuple(v_val.shape))

torch.manual_seed(0)
cabeza = nn.Linear(512, 4)
cargador_ent = DataLoader(TensorDataset(v_ent, y_ent), batch_size=32,
                          shuffle=True,
                          generator=torch.Generator().manual_seed(0))
cargador_val = DataLoader(TensorDataset(v_val, y_val), batch_size=400)
optimizador = torch.optim.Adam(cabeza.parameters(), lr=1e-3)
hist_cabeza = entrenar(cabeza, cargador_ent, cargador_val, epocas=30,
                       optimizador=optimizador, dispositivo=device,
                       mostrar=False)
exactitud = hist_cabeza["exactitud_val"]
print("Parámetros entrenables:",
      sum(p.numel() for p in cabeza.parameters()))
print(f"Exactitud de validación: época 1 {exactitud[0]:.3f}, "
      f"época 10 {exactitud[9]:.3f}, época 30 {exactitud[-1]:.3f}")
torch.manual_seed(0)
completa = resnet18(weights=pesos)
completa.fc = nn.Linear(512, 4)          # cabeza nueva, 4 clases
cargador_ent = DataLoader(TensorDataset(x_ent, y_ent), batch_size=32,
                          shuffle=True,
                          generator=torch.Generator().manual_seed(0))
cargador_val = DataLoader(TensorDataset(x_val, y_val), batch_size=100)
optimizador = torch.optim.Adam(completa.parameters(), lr=1e-4)
hist_completa = entrenar(completa, cargador_ent, cargador_val, epocas=2,
                         optimizador=optimizador, dispositivo=device)
entrenables = sum(p.numel() for p in completa.parameters()
                  if p.requires_grad)
print(f"Parámetros entrenables: {entrenables:,}".replace(",", " "))
# La prueba: 25 fotos nuevas por clase, que nadie miró hasta ahora
usadas = set(idx_ent) | set(idx_val)
rng_prueba = np.random.default_rng(0)
idx_pru = []
for clase in CLASES:
    de_la_clase = np.flatnonzero(etiquetas == eurosat.class_to_idx[clase])
    libres = [i for i in de_la_clase if i not in usadas]
    idx_pru += list(rng_prueba.choice(libres, size=25, replace=False))
x_pru = torch.stack([preparar(eurosat[i][0]) for i in idx_pru])
y_pru = torch.arange(4).repeat_interleave(25)
cargador_pru = DataLoader(TensorDataset(x_pru, y_pru), batch_size=100)
_, exactitud_pru = evaluar(completa, cargador_pru, nn.CrossEntropyLoss(),
                           device)
print(f"Exactitud de prueba (una sola vez): {exactitud_pru:.3f}")
from sklearn.linear_model import LogisticRegression
from torchvision.transforms.functional import to_tensor

# Fotos originales de 64 × 64, normalizadas y reducidas a 32 × 32
r_ent = torch.stack([to_tensor(f) for f in fotos_ent])
r_val = torch.stack([to_tensor(f) for f in fotos_val])
media = r_ent.mean(dim=(0, 2, 3), keepdim=True)
desv = r_ent.std(dim=(0, 2, 3), keepdim=True)
r_ent = nn.functional.avg_pool2d((r_ent - media) / desv, 2)
r_val = nn.functional.avg_pool2d((r_val - media) / desv, 2)


def cnn_pequena():
    return nn.Sequential(
        nn.Conv2d(3, 32, 3, padding=1), nn.BatchNorm2d(32), nn.ReLU(),
        nn.MaxPool2d(2),
        nn.Conv2d(32, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(),
        nn.MaxPool2d(2),
        nn.Conv2d(64, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(),
        nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Linear(64, 4))


def desde_cero(x, y, pasos=300):
    """Entrena una CNN nueva y devuelve su exactitud de validación."""
    torch.manual_seed(0)
    modelo = cnn_pequena().to(device)
    opt = torch.optim.Adam(modelo.parameters(), lr=1e-3)
    g = torch.Generator().manual_seed(0)
    modelo.train()
    for _ in range(pasos):
        i = torch.randint(len(x), (32,), generator=g)
        xb, yb = x[i], y[i]
        # Aumentación: voltear horizontal y verticalmente al azar
        if torch.rand(1, generator=g) < 0.5:
            xb = xb.flip(3)
        if torch.rand(1, generator=g) < 0.5:
            xb = xb.flip(2)
        opt.zero_grad()
        perdida = nn.functional.cross_entropy(modelo(xb.to(device)),
                                              yb.to(device))
        perdida.backward()
        opt.step()
    modelo.eval()
    with torch.no_grad():
        pred = modelo(r_val.to(device)).argmax(dim=1).cpu()
    return (pred == y_val).float().mean().item()


por_clase = [5, 10, 20, 50, 100]
exact_cero, exact_transfer = [], []
for n in por_clase:
    # Las primeras n fotos de cada clase
    sel = torch.cat([torch.arange(k * 100, k * 100 + n) for k in range(4)])
    sonda = LogisticRegression(max_iter=2000)
    sonda.fit(v_ent[sel].numpy(), y_ent[sel].numpy())
    exact_transfer.append(sonda.score(v_val.numpy(), y_val.numpy()))
    exact_cero.append(desde_cero(r_ent[sel], y_ent[sel]))
    print(f"{n:3d} por clase:  desde cero {exact_cero[-1]:.3f}   "
          f"transfer {exact_transfer[-1]:.3f}")

plt.plot(por_clase, exact_cero, "o-", color="C0", label="Desde cero")
plt.plot(por_clase, exact_transfer, "o-", color="C1",
         label="Transfer (ResNet-18)")
plt.axhline(0.25, color="C2", linestyle="--")
plt.text(5, 0.27, "al azar", color="C2")
plt.xscale("log")
plt.xticks(por_clase, [str(n) for n in por_clase])
plt.ylim(0, 1)
plt.xlabel("Fotos por clase para entrenar")
plt.ylabel("Exactitud de validación")
plt.title("¿Cuántas fotos hacen falta?")
plt.legend(loc="center right")
plt.show()

Aprender sin etiquetas

import torch.nn.functional as F
from torchvision import datasets, transforms

moda = datasets.FashionMNIST(root="datos", train=True, download=True)
g = torch.Generator().manual_seed(0)
elegidas = torch.randperm(len(moda), generator=g)[:4000]
# Solo las imágenes: las etiquetas no se usan
x_moda = moda.data[elegidas].float().div(255).unsqueeze(1)

aumentar = transforms.Compose([
    transforms.RandomResizedCrop(28, scale=(0.4, 1.0)),
    transforms.RandomHorizontalFlip()])


def vista(x):
    """Una versión aumentada distinta de cada imagen del lote."""
    return torch.stack([aumentar(im) for im in x])


def perdida_contrastiva(z1, z2, tau=0.5):
    """z1[i] y z2[i] son las dos vistas de la foto i."""
    z = F.normalize(torch.cat([z1, z2]), dim=1)
    sim = z @ z.T / tau                   # similitud coseno / tau
    sim.fill_diagonal_(float("-inf"))     # nadie es su propia pareja
    n = len(z1)
    pareja = torch.cat([torch.arange(n, 2 * n), torch.arange(n)])
    return F.cross_entropy(sim, pareja)


torch.manual_seed(0)
codificador = nn.Sequential(
    nn.Conv2d(1, 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 * 7 * 7, 128))
proyector = nn.Sequential(nn.ReLU(), nn.Linear(128, 64))

# Un lote fijo de 256 imágenes con sus dos vistas, para medir
v1, v2 = vista(x_moda[:256]), vista(x_moda[:256])


def medir():
    codificador.eval()
    with torch.no_grad():
        z1, z2 = proyector(codificador(v1)), proyector(codificador(v2))
        sim = F.normalize(z1, dim=1) @ F.normalize(z2, dim=1).T
        acierto = (sim.argmax(dim=1) == torch.arange(256)).float().mean()
    return perdida_contrastiva(z1, z2).item(), acierto.item()


print(f"Pérdida si adivina al azar: ln(511) = {np.log(511):.2f}")
perdida, acierto = medir()
print(f"Antes:   pérdida {perdida:.2f}, encuentra su pareja {acierto:.1%}")

parametros = list(codificador.parameters()) + list(proyector.parameters())
opt = torch.optim.Adam(parametros, lr=1e-3)
codificador.train()
orden = torch.randperm(len(x_moda), generator=g)
for i in range(0, len(x_moda), 256):         # una época: 16 lotes
    x = x_moda[orden[i:i + 256]]
    perdida = perdida_contrastiva(proyector(codificador(vista(x))),
                                  proyector(codificador(vista(x))))
    opt.zero_grad()
    perdida.backward()
    opt.step()
perdida, acierto = medir()
print(f"Después: pérdida {perdida:.2f}, encuentra su pareja {acierto:.1%}")
from transformers import AutoModel, AutoImageProcessor
from sklearn.neighbors import KNeighborsClassifier

DINO = "facebook/dinov2-small"
proc_dino = AutoImageProcessor.from_pretrained(DINO)
dino = AutoModel.from_pretrained(DINO).to(device).eval()


def variables_dino(fotos, lote=50):
    partes = []
    with torch.no_grad():
        for i in range(0, len(fotos), lote):
            x = proc_dino(images=fotos[i:i + lote], return_tensors="pt")
            partes.append(dino(**x.to(device)).pooler_output.cpu())
    return torch.cat(partes)


d_ent = variables_dino(fotos_ent)
d_val = variables_dino(fotos_val)
print("Variables de DINOv2:", tuple(d_ent.shape))

for nombre, a, b in [("DINOv2", d_ent, d_val), ("ResNet-18", v_ent, v_val)]:
    knn = KNeighborsClassifier(n_neighbors=5, metric="cosine")
    knn.fit(a.numpy(), y_ent.numpy())
    print(f"k-NN (k = 5) con {nombre}: "
          f"{knn.score(b.numpy(), y_val.numpy()):.3f}")
import umap

todas = torch.cat([d_ent, d_val]).numpy()
y_todas = torch.cat([y_ent, y_val]).numpy()
plano = umap.UMAP(n_neighbors=15, min_dist=0.1, metric="cosine",
                  random_state=0, n_jobs=1).fit_transform(todas)

fig, eje = plt.subplots()
for k, nombre in enumerate(NOMBRES):
    puntos = plano[y_todas == k]
    color = "C1" if nombre == "río" else "C2"
    eje.scatter(puntos[:, 0], puntos[:, 1], s=8, color=color)
    cx, cy = np.median(puntos, axis=0)
    eje.text(cx, cy, nombre, ha="center", va="center", fontsize=13,
             color="C0",
             bbox=dict(boxstyle="round,pad=0.2", facecolor="white",
                       edgecolor="none", alpha=0.85))
eje.set_xticks([])
eje.set_yticks([])
eje.set_title("800 fotos según DINOv2 (UMAP)")
plt.show()

Un transformer que mira fotos: ViT

import urllib.request
from pathlib import Path
from PIL import Image

BASE = "https://upload.wikimedia.org/wikipedia/commons/thumb/"
COMMONS = {
    "bicicleta": ("0/04", "Patch_of_grass_with_view_on_the_church_and_a_"
                  "bicycle_on_Camermanstraat_in_Doel%2C_Belgium_"
                  "%28DSCF3846%29.jpg"),
    "frutas": ("9/98", "Fruits_in_the_table.jpg"),
    "gato": ("6/6c", "Domestic_cat_2019_G1.jpg"),
    "guitarra": ("c/c3", "Guitar_May_2009-1.jpg"),
    "montaña": ("5/5a", "Mountains_in_snow%2C_Mountain_lake%2C_Chola_"
                "Valley%2C_Nepal%2C_Himalayas.jpg"),
    "perro": ("2/2b", "Dog_in_Chacachacare.jpg"),
}
carpeta = Path("datos/imagenes-ruta")
carpeta.mkdir(parents=True, exist_ok=True)
fotos6 = {}
for nombre, (ruta, archivo) in COMMONS.items():
    destino = carpeta / f"t26-{nombre.replace('ñ', 'n')}.jpg"
    if not destino.exists():
        url = f"{BASE}{ruta}/{archivo}/960px-{archivo}"
        pedido = urllib.request.Request(
            url, headers={"User-Agent": "ruta-ia-obia/1.0 (curso)"})
        destino.write_bytes(urllib.request.urlopen(pedido).read())
    fotos6[nombre] = Image.open(destino).convert("RGB")
print("Fotos:", list(fotos6))
from transformers import AutoModelForImageClassification

DEIT = "facebook/deit-tiny-patch16-224"
proc_vit = AutoImageProcessor.from_pretrained(DEIT)
vit = AutoModelForImageClassification.from_pretrained(DEIT)
vit = vit.to(device).eval()
n_vit = sum(p.numel() for p in vit.parameters())
print(f"Parámetros: {n_vit:,}".replace(",", " "),
      "  parche:", vit.config.patch_size)

x = proc_vit(images=list(fotos6.values()), return_tensors="pt")
print("Entrada:", tuple(x["pixel_values"].shape))
with torch.no_grad():
    prob = vit(**x.to(device)).logits.softmax(dim=1).cpu()
for nombre, p in zip(fotos6, prob):
    k = p.argmax().item()
    etiqueta = vit.config.id2label[k].split(",")[0]
    print(f"{nombre:10s} -> {etiqueta} ({p[k]:.1%})")

CLIP: fotos y frases en el mismo espacio

from matplotlib.colors import LinearSegmentedColormap
from transformers import CLIPModel, CLIPProcessor

CLIP = "openai/clip-vit-base-patch32"
clip = CLIPModel.from_pretrained(CLIP).to(device).eval()
proc_clip = CLIPProcessor.from_pretrained(CLIP)

frases = ["a photo of a bicycle on the grass",  # una bici sobre el pasto
          "a bowl of fruit on a table",  # un frutero sobre una mesa
          "a cat sleeping on a chair",  # un gato durmiendo en una silla
          "the head of a guitar",  # el clavijero de una guitarra
          "snowy mountains and a lake",  # montañas nevadas y un lago
          "a dog lying by the sea"]  # un perro echado junto al mar
x = proc_clip(text=frases, images=list(fotos6.values()),
              return_tensors="pt", padding=True)
with torch.no_grad():
    salida = clip(**x.to(device))
# Una fila por foto y una columna por frase: escala · coseno
logits = salida.logits_per_image.cpu()
escala = clip.logit_scale.exp().item()
print(f"Escala aprendida: {escala:.0f}, o sea, tau = {1 / escala:.2f}")
sim = logits / escala
print("Frase elegida para cada foto:", logits.argmax(dim=1).tolist())

cortas = ["bicicleta", "frutero", "gato", "guitarra", "montañas",
          "perro"]
fig, eje = plt.subplots(figsize=(4.8, 4.8))
eje.imshow(sim, cmap=LinearSegmentedColormap.from_list(
    "tinta", ["white", "C0"]), vmin=0.05, vmax=0.35)
for i in range(6):
    for j in range(6):
        eje.text(j, i, f"{sim[i, j]:.2f}".replace(".", ","),
                 ha="center", va="center", fontsize=11,
                 color="white" if sim[i, j] > 0.22 else "C0")
    # La pareja correcta de cada fila, con borde coral
    eje.add_patch(plt.Rectangle((i - 0.5, i - 0.5), 1, 1, fill=False,
                                edgecolor="C1", linewidth=3))
eje.grid(False)
eje.set_xticks(range(6), cortas, rotation=45, ha="right")
eje.set_yticks(range(6), list(fotos6))
eje.set_xlabel("Frase")
eje.set_ylabel("Foto")
eje.set_title("Similitud coseno según CLIP")
plt.show()
correctas = torch.arange(6)
perdida_filas = F.cross_entropy(logits, correctas)
perdida_columnas = F.cross_entropy(logits.T, correctas)
print(f"Pérdida contrastiva: "
      f"{(perdida_filas + perdida_columnas).item() / 2:.6f}")

for tau in [0.1, 0.03, 0.01]:
    p = (sim[2] / tau).softmax(dim=0)        # fila del gato
    print(f"tau = {tau:<4}: probabilidad de su frase {p[2]:.3f}, "
          f"pérdida de la fila {-p[2].log():.3f}")
def codificar_fotos(fotos, lote=100):
    partes = []
    with torch.no_grad():
        for i in range(0, len(fotos), lote):
            x = proc_clip(images=fotos[i:i + lote], return_tensors="pt")
            salida = clip.get_image_features(**x.to(device))
            partes.append(salida.pooler_output.cpu())
    return F.normalize(torch.cat(partes), dim=1)


def codificar_textos(textos):
    x = proc_clip(text=textos, return_tensors="pt", padding=True)
    with torch.no_grad():
        salida = clip.get_text_features(**x.to(device))
    return F.normalize(salida.pooler_output.cpu(), dim=1)


from sklearn.metrics import confusion_matrix

c_val = codificar_fotos(fotos_val)
EN = ["a forest", "a highway", "a residential area", "a river"]
plantilla = "a satellite photo of {}"
t_clases = codificar_textos([plantilla.format(c) for c in EN])
pred = (c_val @ t_clases.T).argmax(dim=1)
print(f"Exactitud zero-shot: {(pred == y_val).float().mean():.3f}")
print("Matriz de confusión (filas: clase real):")
print(confusion_matrix(y_val, pred))
consulta = "a satellite photo of a river"
s = (c_val @ codificar_textos([consulta]).T).squeeze(1)
mejores = s.topk(5).indices.tolist()
print("Clases de las 5 más parecidas:",
      [NOMBRES[y_val[k]] for k in mejores])

fig, ejes = plt.subplots(2, 3, figsize=(4.8, 3.6))
ejes[0, 0].text(0.5, 0.5, "Consulta:\n«a satellite\nphoto of\na river»",
                ha="center", va="center", fontsize=13, color="C0")
ejes[0, 0].axis("off")
for eje, k in zip(ejes.flat[1:], mejores):
    eje.imshow(fotos_val[k])
    # En coral, la que no es un río
    color = "C0" if NOMBRES[y_val[k]] == "río" else "C1"
    eje.set_title(f"{NOMBRES[y_val[k]]} {s[k]:.2f}".replace(".", ","),
                  fontsize=12, color=color)
    eje.axis("off")
plt.show()