Visión moderna: modelos preentrenados, autoaprendizaje y CLIP

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

En esta ficha · 5 secciones
  1. Antes de empezar
  2. Ejercicios
  3. Mini-proyecto: tu propio clasificador de objetos
  4. Errores comunes
  5. ¿Lo lograste?

Antes de empezar

Varios ejercicios usan las variables y funciones de la teoría: eurosat, CLASES, NOMBRES, fotos_ent, fotos_val, y_ent, y_val, pesos, preparar, x_ent, x_val, red (ResNet-18 sin su cabeza), variables, v_ent, v_val, entrenar, evaluar, d_ent, d_val (las variables de DINOv2), fotos6, frases, proc_vit, vit, clip, proc_clip, codificar_fotos, codificar_textos, c_val y EN. Abre un cuaderno nuevo en Colab, pega y ejecuta en orden los bloques de la ficha Código y después trabaja debajo. Los modelos ocupan cerca de 800 MB entre todos; la primera vez tardan un poco en descargarse.

Intenta cada ejercicio antes de abrir la respuesta: equivocarse primero y corregir después es como se aprende de verdad.

Ejercicios

1. ¿Qué harías?

Para cada caso, di si conviene entrenar solo la cabeza, hacer un ajuste completo, usar CLIP zero-shot o entrenar desde cero, y por qué.

  1. Tienes 30 fotos de cada una de tres plantas de tu jardín y una computadora sin GPU.
  2. Tienes 20 000 radiografías etiquetadas y acceso a una GPU durante una semana.
  3. Quieres ordenar las fotos de tu celular en «comida», «paisajes» y «mascotas» y no quieres etiquetar ninguna.
  4. Tienes 5000 fotos de microscopio de un tipo de célula que no se parece a nada de internet, y una GPU.
Respuesta
  1. Solo la cabeza (o una sonda lineal): con 30 fotos por clase, un ajuste completo puede sobreajustar, y calcular las variables una vez es barato sin GPU. Si después quieres un poco más, puedes ajustar solo las últimas capas.
  2. Ajuste completo desde una red preentrenada: con muchos datos y GPU, retocar todos los filtros suele dar lo mejor. Entrenar desde cero también sería posible, pero partir de pesos preentrenados casi nunca empeora y converge más rápido.
  3. CLIP zero-shot, con plantillas como «a photo of food». Son clases comunes en internet, justo lo que CLIP vio.
  4. Ajuste completo de una red preentrenada, probablemente con varias épocas. Las fotos se parecen poco a ImageNet y a internet, así que la cabeza sola y CLIP zero-shot rendirían peor; aun así, los bordes y texturas de las primeras capas sirven. Desde cero también es razonable con 5000 fotos; lo correcto es probar las dos opciones y medir en validación.

2. Contar lo que se entrena

Carga una ResNet-18 nueva con una cabeza de 4 clases y cuenta los parámetros entrenables en tres casos: todo, solo la cabeza, y la cabeza más el último bloque (layer4). Para congelar, pon requires_grad = False a todos los parámetros y después devuélvelo a True en los que quieres entrenar.

Respuesta
modelo = resnet18(weights=pesos)
modelo.fc = nn.Linear(512, 4)


def entrenables(m):
    return sum(p.numel() for p in m.parameters() if p.requires_grad)


total = entrenables(modelo)
print(f"Todo:                  {total:>10,}".replace(",", " "))
for p in modelo.parameters():
    p.requires_grad = False
for p in modelo.fc.parameters():
    p.requires_grad = True
print(f"Solo la cabeza:        {entrenables(modelo):>10,}"
      .replace(",", " "))
for p in modelo.layer4.parameters():
    p.requires_grad = True
n = entrenables(modelo)
print(f"layer4 y la cabeza:    {n:>10,}".replace(",", " "),
      f"({n / total:.0%})")
Todo:                  11 178 564
Solo la cabeza:             2 052
layer4 y la cabeza:     8 395 780 (75%)

La cabeza tiene 512 · 4 + 4 = 2052 parámetros, menos del 0,02 % de la red. El último bloque solo ya tiene cerca de tres cuartos de todos: las capas finales de una CNN tienen muchos canales, y cada filtro de 3 × 3 × 512 tiene 4608 pesos. Ajustar «solo las últimas capas» ahorra cálculo (el gradiente no tiene que bajar hasta el principio), pero no tantos parámetros como podrías pensar.

3. Cabeza o ajuste completo, con pocas fotos

En la teoría, con 100 fotos por clase, el ajuste completo le ganó a la cabeza sola. ¿Pasa lo mismo con 10 fotos por clase? Entrena una sonda lineal con las variables de esas 40 fotos y, aparte, ajusta toda la red 5 épocas con ellas (lotes de 8, tasa 0,0001). Compara las dos en las 400 fotos de validación. entrenar pide un cargador de validación; como no vamos a mirar esos números, le pasamos el mismo de entrenamiento con mostrar=False, y medimos la validación una sola vez al final con evaluar.

Respuesta
sel = torch.cat([torch.arange(k * 100, k * 100 + 10) for k in range(4)])
sonda = LogisticRegression(max_iter=2000)
sonda.fit(v_ent[sel].numpy(), y_ent[sel].numpy())
print(f"Solo la cabeza:  {sonda.score(v_val.numpy(), y_val.numpy()):.3f}")

torch.manual_seed(0)
pocas = resnet18(weights=pesos)
pocas.fc = nn.Linear(512, 4)
cargador_pocas = DataLoader(TensorDataset(x_ent[sel], y_ent[sel]),
                            batch_size=8, shuffle=True,
                            generator=torch.Generator().manual_seed(0))
entrenar(pocas, cargador_pocas, cargador_pocas, epocas=5,
         optimizador=torch.optim.Adam(pocas.parameters(), lr=1e-4),
         dispositivo=device, mostrar=False)
cargador_val = DataLoader(TensorDataset(x_val, y_val), batch_size=100)
_, exactitud = evaluar(pocas, cargador_val, nn.CrossEntropyLoss(), device)
print(f"Ajuste completo: {exactitud:.3f}")
Solo la cabeza:  0.833
Ajuste completo: 0.865

Con 10 fotos por clase las dos formas quedan cerca. El ajuste completo gana por poco, pero con 40 fotos y 11 millones de parámetros el resultado depende mucho de la semilla y de la tasa, y cuesta mucho más tiempo. Con pocas fotos, la cabeza sola es la apuesta segura; el ajuste completo rinde más cuando hay más datos (como en la teoría) o cuando las fotos se parecen poco a las del preentrenamiento.

4. Diseña plantillas para CLIP

En la teoría, la plantilla «a satellite photo of …» dio la exactitud zero-shot de EuroSAT. Prueba estas plantillas con las mismas cuatro clases de EN y mide la exactitud de cada una:

  • solo el nombre de la clase, sin plantilla;
  • la de la teoría, «a satellite photo of …»;
  • «a satellite photo of ….» (con punto final);
  • «a photo of ….»;
  • «an aerial view of …».

Después prueba el promedio de plantillas: codifica las cinco frases de cada clase, promedia sus vectores, normaliza y clasifica con ese vector promedio.

Respuesta
plantillas = ["{}", "a satellite photo of {}", "a satellite photo of {}.",
              "a photo of {}.", "an aerial view of {}."]


def exactitud_zero_shot(t):
    pred = (c_val @ t.T).argmax(dim=1)
    return (pred == y_val).float().mean().item()


vectores = []
for p in plantillas:
    t = codificar_textos([p.format(c) for c in EN])
    vectores.append(t)
    print(f"{p!r:28s} {exactitud_zero_shot(t):.3f}")

promedio = F.normalize(torch.stack(vectores).mean(dim=0), dim=1)
print(f"{'promedio de las cinco':28s} {exactitud_zero_shot(promedio):.3f}")
'{}'                         0.678
'a satellite photo of {}'    0.592
'a satellite photo of {}.'   0.530
'a photo of {}.'             0.637
'an aerial view of {}.'      0.700
promedio de las cinco        0.668

Un detalle tan chico como el punto final cambia mucho la exactitud: CLIP no «entiende» la frase como una persona, la convierte en un vector que depende de cada token. Por eso hay que probar varias plantillas y medir con fotos etiquetadas, aunque sean pocas. Promediar plantillas suele dar un resultado más estable que elegir una sola (los autores de CLIP promediaron 80 plantillas para ImageNet), pero aquí no le gana a la mejor plantilla: tampoco es magia. Ojo: si eliges la plantilla mirando la exactitud de prueba, esa exactitud ya no es una medida honesta; lo correcto es elegirla con un conjunto de validación aparte.

5. La pérdida contrastiva a mano

Un lote de dos fotos y sus dos textos tiene esta matriz de similitudes coseno (fila: foto; columna: texto):

texto 1 texto 2
foto 1 0,30 0,10
foto 2 0,20 0,25

Con τ = 0,1, calcula a mano la probabilidad que le da cada foto a su texto correcto (softmax por filas) y la pérdida por filas, L = −(log p₁₁ + log p₂₂) / 2. Después compruébalo con F.cross_entropy. ¿Qué pasa con la pérdida si τ = 0,01?

Respuesta

Dividir por τ = 0,1 multiplica por 10: la fila 1 queda (3; 1) y la fila 2, (2; 2,5).

  • p₁₁ = e³ / (e³ + e¹) = 1 / (1 + e⁻²) ≈ 1 / 1,135 ≈ 0,881
  • p₂₂ = e²·⁵ / (e² + e²·⁵) = 1 / (1 + e⁻⁰·⁵) ≈ 1 / 1,607 ≈ 0,622
  • L = −(ln 0,881 + ln 0,622) / 2 ≈ (0,127 + 0,475) / 2 ≈ 0,301
s = torch.tensor([[0.30, 0.10], [0.20, 0.25]])
correctas = torch.tensor([0, 1])
for tau in [0.1, 0.01]:
    p = (s / tau).softmax(dim=1)
    perdida = F.cross_entropy(s / tau, correctas)
    print(f"tau = {tau}: p11 = {p[0, 0]:.3f}, p22 = {p[1, 1]:.3f}, "
          f"pérdida = {perdida:.3f}")
tau = 0.1: p11 = 0.881, p22 = 0.622, pérdida = 0.301
tau = 0.01: p11 = 1.000, p22 = 0.993, pérdida = 0.003

Con τ = 0,01, diferencias de similitud de 0,05 se vuelven diferencias de 5 antes del softmax, y las dos fotos dan casi toda la probabilidad a su texto: la pérdida baja casi a cero sin que cambie ningún vector. Por eso la temperatura se aprende junto con la red, y por eso una pérdida chica solo se puede comparar con otra calculada con la misma τ.

6. ¿CLIP entiende español?

CLIP se entrenó con textos en inglés. Repite la matriz de la teoría con las frases traducidas al español («una foto de una bicicleta sobre el pasto», «un frutero sobre una mesa», «un gato durmiendo en una silla», «el clavijero de una guitarra», «montañas nevadas y una laguna», «un perro echado junto al mar»). ¿Sigue ganando la diagonal? ¿Por cuánto? Después prueba la clasificación zero-shot de EuroSAT con la plantilla «una foto satelital de …» y las clases en español.

Respuesta
frases_es = ["una foto de una bicicleta sobre el pasto",
             "un frutero sobre una mesa",
             "un gato durmiendo en una silla",
             "el clavijero de una guitarra",
             "montañas nevadas y una laguna",
             "un perro echado junto al mar"]
for idioma, textos in [("inglés", frases), ("español", frases_es)]:
    t = codificar_textos(textos)
    s6 = codificar_fotos(list(fotos6.values())) @ t.T
    diagonal = s6.diagonal()
    # El mayor valor de cada fila fuera de la diagonal
    otros = (s6 - 9 * torch.eye(6)).max(dim=1).values
    print(f"{idioma:8s} elige {s6.argmax(dim=1).tolist()}  "
          f"diagonal {diagonal.mean():.3f}  "
          f"margen medio {(diagonal - otros).mean():.3f}")

ES = ["un bosque", "una autopista", "una zona residencial", "un río"]
t = codificar_textos([f"una foto satelital de {c}" for c in ES])
pred = (c_val @ t.T).argmax(dim=1)
print(f"EuroSAT en español: {(pred == y_val).float().mean():.3f}")
inglés   elige [0, 1, 2, 3, 4, 5]  diagonal 0.297  margen medio 0.122
español  elige [0, 1, 2, 3, 4, 5]  diagonal 0.270  margen medio 0.083
EuroSAT en español: 0.598

Con estas seis fotos tan distintas, CLIP todavía acierta las seis en español, pero con similitudes más bajas y menos margen sobre la segunda frase: muchas palabras («bicicleta», «guitarra», «gato») comparten pedazos con el inglés o aparecieron en textos de internet. En EuroSAT, la plantilla en español dio casi lo mismo que la de la teoría en inglés (0,598 frente a 0,592): con estas clases, CLIP se equivoca por otras razones. Que acierte en estos casos no quiere decir que entienda español; con frases que no se parecen al inglés le puede ir mucho peor. Para otro idioma conviene traducir las frases al inglés o usar un CLIP multilingüe, y siempre medir.

7. ¿Cuántos vecinos?

Clasifica las fotos de validación de EuroSAT con k-NN usando las variables de DINOv2 (d_ent, d_val) y las de ResNet-18 (v_ent, v_val), con k = 1, 5 y 15. ¿Qué variables funcionan mejor? ¿Cambia mucho con k?

Respuesta
for nombre, a, b in [("DINOv2", d_ent, d_val), ("ResNet-18", v_ent, v_val)]:
    fila = []
    for k in [1, 5, 15]:
        knn = KNeighborsClassifier(n_neighbors=k, metric="cosine")
        knn.fit(a.numpy(), y_ent.numpy())
        fila.append(f"k = {k:2d}: {knn.score(b.numpy(), y_val.numpy()):.3f}")
    print(f"{nombre:10s}", "   ".join(fila))
DINOv2     k =  1: 0.887   k =  5: 0.907   k = 15: 0.890
ResNet-18  k =  1: 0.877   k =  5: 0.890   k = 15: 0.880

DINOv2 gana con los tres valores de k, aunque nunca vio una etiqueta: el autoaprendizaje con muchísimas fotos dio variables por lo menos tan útiles como las de una red entrenada con etiquetas. Con k = 1 una sola vecina equivocada decide; con k = 5 la votación corrige algunos errores; con k más grande se empiezan a colar vecinas de otras clases. Elegir k mirando la prueba tiene el mismo problema que en el ejercicio 4: para elegirlo bien hace falta validación (tema 7).

8. Un ViT de ImageNet frente a fotos satelitales

Pasa ocho fotos de validación de EuroSAT (dos de cada clase: las de índice 0, 50, 100, 150, 200, 250, 300 y 350) por DeiT-tiny y mira qué etiqueta de ImageNet elige para cada una. ¿Por qué pasa esto? ¿Qué harías para que DeiT-tiny sirva para EuroSAT?

Respuesta
ocho = [fotos_val[i] for i in range(0, 400, 50)]
x = proc_vit(images=ocho, return_tensors="pt")
with torch.no_grad():
    elegidas = vit(**x.to(device)).logits.argmax(dim=1).tolist()
for i, k in zip(range(0, 400, 50), elegidas):
    etiqueta = vit.config.id2label[k].split(",")[0]
    print(f"{NOMBRES[y_val[i]]:12s} -> {etiqueta}")
bosque       -> nematode
bosque       -> gar
autopista    -> nematode
autopista    -> tile roof
residencial  -> sidewinder
residencial  -> hen-of-the-woods
río          -> nematode
río          -> oboe

Las respuestas son absurdas (gusanos, peces, instrumentos), pero tienen lógica: DeiT-tiny tiene que elegir una de sus 1000 clases de ImageNet, y ninguna es «río visto desde un satélite». Elige la clase cuyas texturas se parecen más: un río serpenteante se parece a un gusano; un tejido de techos, a un tejado. Para usarlo con EuroSAT, hay que cambiarle la cabeza por una de 4 clases y entrenarla (o ajustar toda la red), exactamente como hiciste con ResNet-18.

Mini-proyecto: tu propio clasificador de objetos

Vas a construir un clasificador de 3 a 5 objetos cotidianos con tus propias fotos y comparar dos formas de hacerlo: transfer learning con ResNet-18 y CLIP zero-shot.

  1. Saca las fotos. Elige de 3 a 5 objetos de tu casa (una taza, un cuaderno, tus llaves, un par de zapatillas…). Saca unas 50 fotos de cada uno con tu celular: desde distintos ángulos, con distinta luz, sobre distintos fondos y a distintas distancias. Si todas las tazas están sobre la misma mesa y todos los cuadernos sobre la cama, el modelo puede aprender a reconocer la mesa y la cama, no los objetos. No saques fotos de personas.
  2. Ordénalas en carpetas, una por objeto, dentro de una carpeta mis_fotos: mis_fotos/taza/, mis_fotos/cuaderno/, etc. En Colab, súbela comprimida en un .zip (desde el panel de archivos) y descomprímela.
  3. Transfer learning. Calcula las variables de ResNet-18 de cada foto y mide una sonda lineal con validación cruzada de 5 pliegues (tema 7): con 50 fotos por clase no conviene separar un solo conjunto de prueba.
  4. CLIP zero-shot. Escribe en inglés una frase por objeto («a photo of a mug») y clasifica cada foto con la más parecida.
  5. Compara las dos exactitudes, mira la matriz de confusión y mira las fotos que fallan. ¿Qué tienen en común? Prueba al menos dos plantillas para CLIP.
  6. Extra: ajusta toda la red unas épocas (como en la teoría) separando un 20 % de las fotos para validación, y compara.

El código lee cualquier carpeta con esa forma. Para que puedas probarlo antes de sacar tus fotos, si no encuentra mis_fotos arma una de prueba con 20 fotos de EuroSAT por clase que no usamos en la teoría.

from PIL import ImageOps
from sklearn.model_selection import cross_val_score

CARPETA = Path("mis_fotos")

# Solo para probar el código sin tus fotos: 20 fotos de EuroSAT por clase
if not CARPETA.exists():
    usadas = set(idx_ent) | set(idx_val)
    rng = np.random.default_rng(0)
    for clase, nombre in zip(CLASES, NOMBRES):
        de_la_clase = np.flatnonzero(
            etiquetas == eurosat.class_to_idx[clase])
        libres = [i for i in de_la_clase if i not in usadas]
        (CARPETA / nombre).mkdir(parents=True)
        for k, i in enumerate(rng.choice(libres, size=20, replace=False)):
            eurosat[i][0].save(CARPETA / nombre / f"{k:02d}.jpg")

clases = sorted(d.name for d in CARPETA.iterdir() if d.is_dir())
mis_fotos, mis_y = [], []
for k, clase in enumerate(clases):
    for archivo in sorted((CARPETA / clase).iterdir()):
        if archivo.suffix.lower() in {".jpg", ".jpeg", ".png"}:
            foto = Image.open(archivo)
            # Gira según el celular y achica: las fotos son enormes
            foto = ImageOps.exif_transpose(foto).convert("RGB")
            foto.thumbnail((512, 512))
            mis_fotos.append(foto)
            mis_y.append(k)
mis_y = np.array(mis_y)
print("Clases:", clases)
print("Fotos por clase:", np.bincount(mis_y).tolist())
Clases: ['autopista', 'bosque', 'residencial', 'río']
Fotos por clase: [20, 20, 20, 20]
Resultados de referencia, con fotos de EuroSAT

Este código hace los pasos 3 a 5. Con tus fotos, cambia las frases de DESCRIPCION por las de tus objetos.

from sklearn.metrics import confusion_matrix

# Paso 3: ResNet-18 congelada y sonda lineal, validación cruzada
x = torch.stack([preparar(f) for f in mis_fotos])
mis_v = variables(red, x).numpy()
puntajes = cross_val_score(LogisticRegression(max_iter=2000), mis_v,
                           mis_y, cv=5)
print(f"Transfer (5 pliegues): {puntajes.mean():.3f} "
      f"(de {puntajes.min():.3f} a {puntajes.max():.3f})")

# Paso 4: CLIP zero-shot, una frase en inglés por carpeta
DESCRIPCION = {"autopista": "a satellite photo of a highway",
               "bosque": "a satellite photo of a forest",
               "residencial": "a satellite photo of a residential area",
               "río": "a satellite photo of a river"}
t = codificar_textos([DESCRIPCION[c] for c in clases])
pred = (codificar_fotos(mis_fotos) @ t.T).argmax(dim=1).numpy()
print(f"CLIP zero-shot: {(pred == mis_y).mean():.3f}")

# Paso 5: dónde se equivoca CLIP
print(clases)
print(confusion_matrix(mis_y, pred))
fallas = np.flatnonzero(pred != mis_y)
print("Fotos que fallan:", [f"{clases[mis_y[i]]}->{clases[pred[i]]}"
                            for i in fallas[:4]])
Transfer (5 pliegues): 0.812 (de 0.750 a 0.938)
CLIP zero-shot: 0.525
['autopista', 'bosque', 'residencial', 'río']
[[18  0  2  0]
 [18  0  0  2]
 [ 0  1 19  0]
 [12  0  3  5]]
Fotos que fallan: ['autopista->residencial', 'autopista->residencial', 'bosque->autopista', 'bosque->autopista']
  • Con 20 fotos por clase, la sonda lineal sobre ResNet-18 acierta el 81 % en la validación cruzada (con 16 fotos por clase para entrenar en cada pliegue), y CLIP zero-shot apenas la mitad: con fotos satelitales, que CLIP vio poco, el transfer gana con claridad. Con fotos de objetos cotidianos, que abundan en internet, la distancia suele ser mucho menor.
  • CLIP reconoce bien las autopistas y las zonas residenciales, pero manda casi todos los bosques y muchos ríos a «autopista»: la misma confusión de la teoría. Una plantilla mejor (ejercicio 4) ayudaría.
  • Con tus fotos, la exactitud puede ser alta por una mala razón: si cada objeto se fotografió siempre sobre el mismo fondo. Una prueba honesta: saca 10 fotos nuevas de cada objeto en otro lugar y mide con ellas.

Errores comunes

  • No usar el preprocesamiento del modelo. Cada red preentrenada espera las fotos de cierto tamaño y normalizadas con cierta media y desviación. Usa pesos.transforms() en torchvision o el AutoImageProcessor del modelo en transformers; si no, la red recibe números que nunca vio y rinde mucho peor sin dar ningún error.
  • Olvidar eval() al extraer variables. En modo de entrenamiento, BatchNorm usa las estadísticas del lote y las actualiza, y Dropout apaga neuronas: las variables cambian según con qué otras fotos vinieron.
  • Ajustar toda la red con una tasa de aprendizaje grande. Con 0,001 o más, los primeros lotes pueden estropear los filtros preentrenados. Para el ajuste completo, empieza con 0,0001 o menos.
  • Creer que «congelado» quiere decir «sin cálculo». Una red congelada no aprende, pero cada foto igual pasa por ella. Si la congelas entera, calcula sus variables una vez y guárdalas.
  • Olvidar cambiar la cabeza. resnet18(weights=...) sale con 1000 salidas, las de ImageNet. Para tus clases hay que poner modelo.fc = nn.Linear(512, número_de_clases).
  • Comparar similitudes de CLIP sin normalizar. Los vectores de get_image_features y get_text_features no tienen largo 1: normalízalos antes del producto punto, o compararás largos además de direcciones.
  • Escribirle a CLIP en español. Entiende sobre todo inglés: traduce tus frases o usa un modelo multilingüe, y mide.
  • Elegir la plantilla, k o las épocas mirando la prueba. Lo que eliges con un conjunto ya no lo mide con honestidad: elige con validación y mide la prueba al final.
  • Fotos con el mismo fondo por clase. El modelo aprende el atajo (el fondo) y no el objeto. Varía los fondos y prueba con fotos nuevas.
  • Usar un modelo sin leer su licencia. «Se puede descargar» no quiere decir «se puede usar para cualquier cosa».

¿Lo lograste?

Al terminar este tema deberías poder hacer lo siguiente. Al lado, dónde lo practicaste.

  • Hacer transfer learning con una red preentrenada, entrenando solo la cabeza o ajustando toda la red (ejercicios 1, 2 y 3, y pasos 3 y 6 del mini-proyecto).
  • Explicar el aprendizaje autosupervisado contrastivo y usar las variables de un modelo autosupervisado con k-NN (ejercicio 7).
  • Usar un ViT preentrenado para clasificar y explicar su vocabulario cerrado (ejercicio 8).
  • Clasificar sin entrenar con CLIP y diseñar plantillas (ejercicios 4 y 6, y paso 4 del mini-proyecto).
  • Explicar la pérdida contrastiva como una matriz de similitudes y el papel de la temperatura (ejercicio 5 y la actividad).