Visión moderna: modelos preentrenados, autoaprendizaje y CLIP
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 historialfrom 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()