Phase 04 - Lesson 20

Recuperación de Imágenes y Aprendizaje de Métrica

This lesson includes a graded coding exercise that runs in your browser, unlocked with lifetime access.

Un sistema de recuperación ordena candidatos según una distancia en el espacio de embeddings. El aprendizaje de métrica es la disciplina de moldear ese espacio para que las distancias signifiquen lo que quieres.

Tipo: Build Lenguajes: Python Prerrequisitos: Fase 4 Lección 14 (ViT), Fase 4 Lección 18 (CLIP) Tiempo: ~45 minutos

Objetivos de Aprendizaje

  • Explicar las pérdidas de aprendizaje de métrica triplet, contrastiva y basada en proxy, y elegir la correcta para un conjunto de datos dado
  • Implementar correctamente la normalización L2 y la similitud de coseno, y auditar la diferencia entre recuperación de "mismo ítem" y "misma clase"
  • Construir un índice FAISS, consultarlo por texto y por imagen, y reportar el recall@K para un conjunto de consultas reservado
  • Usar DINOv2, CLIP y SigLIP como backbones de embedding listos para usar y saber cuándo gana cada uno

El Problema

La recuperación está en todas partes en la visión por computador en producción: detección de duplicados, búsqueda inversa de imágenes, búsqueda visual ("encuentra productos similares"), reidentificación facial, reidentificación de personas para vigilancia, emparejamiento a nivel de instancia para e-commerce. La pregunta de producto es siempre la misma: "dada esta imagen de consulta, ordena mi catálogo."

Dos decisiones de diseño moldean todo el sistema. El embedding — qué modelo produce los vectores. El índice — cómo encontrar vecinos más cercanos a escala. Ambos son commodity en 2026 (DINOv2 para el embedding, FAISS para el índice), lo que sube el listón: la parte difícil es definir qué cuenta como similar para tu aplicación y, luego, moldear el espacio de embeddings para que las distancias correspondan.

Ese moldeado es el aprendizaje de métrica. Es una disciplina pequeña pero de alto apalancamiento.

El Concepto

La recuperación de un vistazo

flowchart LR
    Q["Imagen de consulta<br/>o texto"] --> ENC["Encoder"]
    ENC --> EMB["Embedding de la consulta"]
    EMB --> IDX["Índice FAISS"]
    CAT["Imágenes del catálogo"] --> ENC2["Encoder (mismo)"] --> IDX_BUILD["Construir índice"]
    IDX_BUILD --> IDX
    IDX --> RANK["Top-k más cercanos<br/>por coseno / L2"]
    RANK --> OUT["Resultados ordenados"]

    style ENC fill:#dbeafe,stroke:#2563eb
    style IDX fill:#fef3c7,stroke:#d97706
    style OUT fill:#dcfce7,stroke:#16a34a

Las cuatro familias de pérdida

Pérdida Requiere Pros Contras
Contrastiva (ancla, positivo) + negativos Simple, funciona con cualquier etiqueta de par Convergencia lenta sin muchos negativos
Triplet (ancla, positivo, negativo) Intuitiva; control directo de margen La minería de triplets difíciles es costosa
NT-Xent / InfoNCE Pares + negativos minados en el lote Escala a lotes grandes Necesita lote grande o cola de momentum
Basada en proxy (ProxyNCA) Solo etiquetas de clase Rápida, estable, sin minería Puede sobreajustarse a los proxies en conjuntos pequeños

Para la mayoría de los casos de uso en producción, comienza con un backbone preentrenado y solo agrega un fine-tune de aprendizaje de métrica si los embeddings listos para usar tienen un rendimiento bajo en tu conjunto de prueba.

La pérdida triplet formalmente

L = max(0, ||f(a) - f(p)||^2 - ||f(a) - f(n)||^2 + margin)

Acerca el ancla a al positivo p, aléjala del negativo n, con un margin que garantice un intervalo. La estructura de tres imágenes generaliza a cualquier ordenamiento de similitud.

La minería importa: los triplets fáciles (n ya lejos de a) contribuyen con pérdida cero; solo los triplets difíciles enseñan a la red. La minería semi-difícil (n más lejos que p pero dentro del margen) es la receta de FaceNet de 2016 y aún domina.

Similitud de coseno vs L2

Dos métricas, dos convenciones:

  • Coseno: ángulo entre vectores. Requiere embeddings normalizados por L2.
  • L2: distancia euclidiana. Funciona en embeddings crudos o normalizados, pero suele emparejarse con L2-normalizado + L2 al cuadrado.

Para la mayoría de las redes modernas las dos son equivalentes: ||a - b||^2 = 2 - 2 cos(a, b) cuando ||a|| = ||b|| = 1. Elige la convención que coincida con el entrenamiento de tu embedding; mezclarlas cambia silenciosamente lo que significa "más cercano".

Recall@K

La métrica estándar de recuperación:

recall@K = fracción de consultas en las que al menos una coincidencia correcta está en los K primeros resultados

Reporta recall@1, @5, @10 lado a lado. Un recall@10 por encima de 0.95 con recall@1 por debajo de 0.5 significa que el espacio de embeddings tiene la estructura correcta, pero el ordenamiento es ruidoso — prueba fine-tunes más largos o un paso de reordenamiento.

Para la detección de duplicados, el precision@K importa más porque cada falso positivo es un error visible para el usuario. Para la búsqueda visual, el recall@K es la señal de producto.

FAISS en un párrafo

Facebook AI Similarity Search. La biblioteca de facto para la búsqueda de vecinos más cercanos. Tres opciones de índice:

  • IndexFlatIP / IndexFlatL2 — fuerza bruta, exacto, sin entrenamiento. Usa hasta ~1M de vectores.
  • IndexIVFFlat — particiona en K celdas, busca solo las pocas celdas más cercanas. Aproximado, rápido, necesita datos de entrenamiento.
  • IndexHNSW — basado en grafo, el más rápido para muchas consultas, índice de gran tamaño.

Para 100k vectores probablemente quieras IndexFlatIP con similitud de coseno. Para 10M quieres IndexIVFFlat. Para 100M+ combinado con cuantización de producto (IndexIVFPQ).

Recuperación a nivel de instancia vs a nivel de categoría

Dos problemas muy diferentes con el mismo nombre:

  • Nivel de categoría — "encuentra gatos en mi catálogo." Similitud condicional a la clase; los embeddings listos para usar de CLIP / DINOv2 funcionan bien.
  • Nivel de instancia — "encuentra este producto exacto en mi catálogo." Necesita discriminación fina entre objetos visualmente similares de la misma clase; los embeddings listos para usar tienen un rendimiento inferior; el fine-tuning con aprendizaje de métrica importa.

Siempre pregunta cuál de los dos estás resolviendo antes de elegir un modelo.

Constrúyelo

Paso 1: Pérdida triplet

import torch
import torch.nn.functional as F

def triplet_loss(anchor, positive, negative, margin=0.2):
    d_ap = F.pairwise_distance(anchor, positive, p=2)
    d_an = F.pairwise_distance(anchor, negative, p=2)
    return F.relu(d_ap - d_an + margin).mean()

Una línea. Funciona en embeddings normalizados por L2 o crudos.

Paso 2: Minería semi-difícil

Dado un lote de embeddings y etiquetas, encuentra el negativo semi-difícil más difícil para cada ancla.

def semi_hard_negatives(emb, labels, margin=0.2):
    dist = torch.cdist(emb, emb)
    same_class = labels[:, None] == labels[None, :]
    diff_class = ~same_class
    N = emb.size(0)

    positives = dist.clone()
    positives[~same_class] = float("-inf")
    positives.fill_diagonal_(float("-inf"))
    pos_idx = positives.argmax(dim=1)

    semi_hard = dist.clone()
    semi_hard[same_class] = float("inf")
    d_ap = dist[torch.arange(N), pos_idx].unsqueeze(1)
    semi_hard[dist <= d_ap] = float("inf")
    neg_idx = semi_hard.argmin(dim=1)

    fallback_mask = semi_hard[torch.arange(N), neg_idx] == float("inf")
    if fallback_mask.any():
        hardest = dist.clone()
        hardest[same_class] = float("inf")
        neg_idx = torch.where(fallback_mask, hardest.argmin(dim=1), neg_idx)
    return pos_idx, neg_idx

Cada ancla recibe el positivo más difícil dentro de la clase y un negativo semi-difícil que está más lejos que el positivo pero dentro del margen.

Paso 3: Recall@K

def recall_at_k(query_emb, gallery_emb, query_labels, gallery_labels, k=1):
    sim = query_emb @ gallery_emb.T
    _, top_k = sim.topk(k, dim=-1)
    matches = (gallery_labels[top_k] == query_labels[:, None]).any(dim=-1)
    return matches.float().mean().item()

Top-k por producto interno en embeddings normalizados por L2 es igual a top-k por coseno. Reporta la proporción media de consultas con al menos un vecino correcto.

Paso 4: Juntándolo todo

import torch
import torch.nn as nn
from torch.optim import Adam

class Encoder(nn.Module):
    def __init__(self, in_dim=128, emb_dim=64):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(in_dim, 128), nn.ReLU(),
            nn.Linear(128, emb_dim),
        )

    def forward(self, x):
        return F.normalize(self.net(x), dim=-1)

torch.manual_seed(0)
num_classes = 6
protos = F.normalize(torch.randn(num_classes, 128), dim=-1)

def sample_batch(bs=32):
    labels = torch.randint(0, num_classes, (bs,))
    x = protos[labels] + 0.15 * torch.randn(bs, 128)
    return x, labels

enc = Encoder()
opt = Adam(enc.parameters(), lr=3e-3)

for step in range(200):
    x, y = sample_batch(32)
    emb = enc(x)
    pos_idx, neg_idx = semi_hard_negatives(emb, y)
    loss = triplet_loss(emb, emb[pos_idx], emb[neg_idx])
    opt.zero_grad(); loss.backward(); opt.step()

Tras unos cientos de pasos los clusters de embedding forman un cluster por clase.

Úsalo

Stacks de producción en 2026:

  • DINOv2 + FAISS — recuperación visual de propósito general. Funciona listo para usar.
  • CLIP + FAISS — cuando las consultas son texto.
  • DINOv2 con fine-tune + FAISS — recuperación a nivel de instancia, reidentificación facial, moda, e-commerce.
  • Milvus / Weaviate / Qdrant — wrappers gestionados de base de datos vectorial alrededor de FAISS o HNSW.

Para la recuperación de instancia de vanguardia, la receta es: backbone DINOv2, agrega una cabeza de embedding, haz fine-tune con una pérdida triplet o InfoNCE en pares etiquetados por instancia, indexa en FAISS.

Entrégalo

Esta lección produce:

  • outputs/prompt-retrieval-loss-picker.md — un prompt que elige triplet / InfoNCE / ProxyNCA para un problema de recuperación dado.
  • outputs/skill-recall-at-k-runner.md — una skill que escribe un harness de evaluación limpio para recall@K con divisiones train/val/gallery y un contrato de datos adecuado.

Ejercicios

  1. (Fácil) Ejecuta el ejemplo de juguete de arriba. Grafica los embeddings con PCA antes y después del entrenamiento para ver cómo se forman los seis clusters.
  2. (Medio) Agrega una implementación de pérdida ProxyNCA: un "proxy" aprendido por clase, cross-entropy estándar sobre la similitud de coseno. Compara la velocidad de convergencia vs la pérdida triplet en los datos de juguete.
  3. (Difícil) Toma 1.000 imágenes de validación de ImageNet, genera embeddings con DINOv2 vía HuggingFace, construye un índice flat en FAISS y reporta recall@{1, 5, 10} contra las mismas imágenes como consultas (debería ser 1.0) y contra una división reservada usando etiquetas de ImageNet como verdad de referencia.

Términos Clave

Término Lo que dice la gente Lo que realmente significa
Aprendizaje de métrica "Moldear el espacio" Entrenar un encoder para que las distancias en su espacio de salida reflejen una similitud objetivo
Pérdida triplet "Acercar y alejar" L = max(0, d(a, p) - d(a, n) + margin); la pérdida canónica de aprendizaje de métrica
Minería semi-difícil "Negativos útiles" Negativos más lejos del ancla que el positivo pero dentro del margen; empíricamente los más informativos
Pérdida basada en proxy "Prototipos de clase" Un proxy aprendido por clase; cross-entropy sobre similitud-con-proxies; sin minería de pares
Recall@K "Tasa de acierto en el Top-K" Fracción de consultas con al menos un resultado correcto en los K primeros
Recuperación de instancia "Encuentra esta cosa exacta" Emparejamiento fino; las features listas para usar suelen tener un rendimiento inferior
FAISS "La biblioteca de NN" La biblioteca de vecinos más cercanos de Facebook; soporta índices exactos y aproximados
HNSW "Índice de grafo" Hierarchical navigable small world; NN aproximado rápido con poco overhead de memoria

Lectura Adicional

0 lifetime access. Curriculum based on AI Engineering from Scratch by Rohit Ghumare (MIT, used under attribution).