Visión moderna: modelos preentrenados, autoaprendizaje y CLIP
Cuaderno del tema. La teoría y los ejercicios con todo su código, listos para ejecutar en Colab sin instalar nada.
Los 16 bloques de código de la teoría, en el mismo orden. Si prefieres armar tu propio cuaderno, cópialos y córrelos en orden; 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()¿Terminaste este tema?
Márcalo y en la ruta verás cuánto llevas. Se guarda solo en este navegador.