Phase 04 - Lesson 20
Recuperação de Imagens e Aprendizado de Métrica
This lesson includes a graded coding exercise that runs in your browser, unlocked with lifetime access.
Um sistema de recuperação ordena candidatos por uma distância no espaço de embeddings. O aprendizado de métrica é a disciplina de moldar esse espaço para que as distâncias signifiquem o que você quer.
Tipo: Build Linguagens: Python Pré-requisitos: Fase 4 Lição 14 (ViT), Fase 4 Lição 18 (CLIP) Tempo: ~45 minutos
Objetivos de Aprendizado
- Explicar as perdas de aprendizado de métrica triplet, contrastiva e baseada em proxy, e escolher a certa para um dado conjunto de dados
- Implementar corretamente a normalização L2 e a similaridade de cosseno, e auditar a diferença entre recuperação de "mesmo item" e "mesma classe"
- Construir um índice FAISS, consultá-lo por texto e por imagem, e reportar o recall@K para um conjunto de consultas retido
- Usar DINOv2, CLIP e SigLIP como backbones de embedding prontos para uso e saber quando cada um vence
O Problema
Recuperação está em todo lugar na visão computacional em produção: detecção de duplicatas, busca reversa de imagens, busca visual ("encontre produtos similares"), reidentificação facial, reidentificação de pessoas para vigilância, correspondência em nível de instância para e-commerce. A pergunta de produto é sempre a mesma: "dada esta imagem de consulta, ordene meu catálogo."
Duas decisões de design moldam todo o sistema. O embedding — qual modelo produz os vetores. O índice — como encontrar vizinhos mais próximos em escala. Ambos são commodity em 2026 (DINOv2 para o embedding, FAISS para o índice), o que eleva o sarrafo: a parte difícil é definir o que conta como similar para a sua aplicação e, então, moldar o espaço de embeddings para que as distâncias correspondam.
Essa moldagem é o aprendizado de métrica. É uma disciplina pequena, mas de alta alavancagem.
O Conceito
Recuperação em um relance
flowchart LR
Q["Imagem de consulta<br/>ou texto"] --> ENC["Encoder"]
ENC --> EMB["Embedding da consulta"]
EMB --> IDX["Índice FAISS"]
CAT["Imagens do catálogo"] --> ENC2["Encoder (mesmo)"] --> IDX_BUILD["Construir índice"]
IDX_BUILD --> IDX
IDX --> RANK["Top-k mais próximos<br/>por cosseno / L2"]
RANK --> OUT["Resultados ordenados"]
style ENC fill:#dbeafe,stroke:#2563eb
style IDX fill:#fef3c7,stroke:#d97706
style OUT fill:#dcfce7,stroke:#16a34a
As quatro famílias de perda
| Perda | Requer | Prós | Contras |
|---|---|---|---|
| Contrastiva | (âncora, positivo) + negativos | Simples, funciona com qualquer rótulo de par | Convergência lenta sem muitos negativos |
| Triplet | (âncora, positivo, negativo) | Intuitiva; controle direto de margem | Mineração de triplets difíceis é cara |
| NT-Xent / InfoNCE | Pares + negativos minerados no lote | Escala para lotes grandes | Precisa de lote grande ou fila de momentum |
| Baseada em proxy (ProxyNCA) | Apenas rótulos de classe | Rápida, estável, sem mineração | Pode sofrer overfitting nos proxies em conjuntos pequenos |
Para a maioria dos casos de uso em produção, comece com um backbone pré-treinado e só adicione um fine-tune de aprendizado de métrica se os embeddings prontos para uso tiverem desempenho ruim no seu conjunto de teste.
A perda triplet formalmente
L = max(0, ||f(a) - f(p)||^2 - ||f(a) - f(n)||^2 + margin)
Puxe a âncora a para perto do positivo p, afaste-a do negativo n, com uma margin que garante um intervalo. A estrutura de três imagens generaliza para qualquer ordenação de similaridade.
A mineração importa: triplets fáceis (n já longe de a) contribuem com perda zero; somente triplets difíceis ensinam a rede. A mineração semi-difícil (n mais longe que p mas dentro da margem) é a receita do FaceNet de 2016 e ainda domina.
Similaridade de cosseno vs L2
Duas métricas, duas convenções:
- Cosseno: ângulo entre vetores. Requer embeddings normalizados por L2.
- L2: distância euclidiana. Funciona em embeddings brutos ou normalizados, mas geralmente é pareada com L2-normalizado + L2 ao quadrado.
Para a maioria das redes modernas as duas são equivalentes: ||a - b||^2 = 2 - 2 cos(a, b) quando ||a|| = ||b|| = 1. Escolha a convenção que combina com o treino do seu embedding; misturá-las muda silenciosamente o que "mais próximo" significa.
Recall@K
A métrica padrão de recuperação:
recall@K = fração de consultas em que pelo menos uma correspondência correta está nos K primeiros resultados
Reporte recall@1, @5, @10 lado a lado. Um recall@10 acima de 0,95 com recall@1 abaixo de 0,5 significa que o espaço de embeddings tem a estrutura certa, mas a ordenação é ruidosa — tente fine-tunes mais longos ou um passo de reordenação.
Para detecção de duplicatas, o precision@K importa mais porque cada falso positivo é um erro visível para o usuário. Para busca visual, o recall@K é o sinal de produto.
FAISS em um parágrafo
Facebook AI Similarity Search. A biblioteca de fato para busca de vizinhos mais próximos. Três escolhas de índice:
IndexFlatIP/IndexFlatL2— força bruta, exato, sem treino. Use até ~1M de vetores.IndexIVFFlat— particiona em K células, busca apenas as poucas células mais próximas. Aproximado, rápido, precisa de dados de treino.IndexHNSW— baseado em grafo, mais rápido para muitas consultas, índice de tamanho grande.
Para 100k vetores você provavelmente quer IndexFlatIP com similaridade de cosseno. Para 10M você quer IndexIVFFlat. Para 100M+ combinado com quantização de produto (IndexIVFPQ).
Recuperação em nível de instância vs em nível de categoria
Dois problemas muito diferentes com o mesmo nome:
- Nível de categoria — "encontre gatos no meu catálogo." Similaridade condicional à classe; embeddings prontos para uso de CLIP / DINOv2 funcionam bem.
- Nível de instância — "encontre este produto exato no meu catálogo." Precisa de discriminação fina entre objetos visualmente similares da mesma classe; embeddings prontos para uso têm desempenho inferior; fine-tuning com aprendizado de métrica importa.
Sempre pergunte qual dos dois você está resolvendo antes de escolher um modelo.
Construa
Passo 1: Perda 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()
Uma linha. Funciona em embeddings normalizados por L2 ou brutos.
Passo 2: Mineração semi-difícil
Dado um lote de embeddings e rótulos, encontre o negativo semi-difícil mais difícil para cada âncora.
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 âncora recebe o positivo mais difícil dentro da classe e um negativo semi-difícil que está mais longe que o positivo mas dentro da margem.
Passo 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 produto interno em embeddings normalizados por L2 é igual a top-k por cosseno. Reporte a proporção média de consultas com pelo menos um vizinho correto.
Passo 4: Juntando tudo
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()
Após algumas centenas de passos os clusters de embedding formam um cluster por classe.
Use
Stacks de produção em 2026:
- DINOv2 + FAISS — recuperação visual de propósito geral. Funciona pronto para uso.
- CLIP + FAISS — quando as consultas são texto.
- DINOv2 com fine-tune + FAISS — recuperação em nível de instância, reidentificação facial, moda, e-commerce.
- Milvus / Weaviate / Qdrant — wrappers gerenciados de banco de dados vetorial em torno de FAISS ou HNSW.
Para recuperação de instância no estado da arte, a receita é: backbone DINOv2, adicione uma cabeça de embedding, faça fine-tune com uma perda triplet ou InfoNCE em pares rotulados por instância, indexe no FAISS.
Entregue
Esta lição produz:
outputs/prompt-retrieval-loss-picker.md— um prompt que escolhe triplet / InfoNCE / ProxyNCA para um dado problema de recuperação.outputs/skill-recall-at-k-runner.md— uma skill que escreve um harness de avaliação limpo para recall@K com divisões train/val/gallery e contrato de dados adequado.
Exercícios
- (Fácil) Execute o exemplo de brinquedo acima. Plote os embeddings com PCA antes e depois do treino para ver os seis clusters se formarem.
- (Médio) Adicione uma implementação de perda ProxyNCA: um "proxy" aprendido por classe, cross-entropy padrão sobre a similaridade de cosseno. Compare a velocidade de convergência vs a perda triplet nos dados de brinquedo.
- (Difícil) Pegue 1.000 imagens de validação do ImageNet, gere embeddings com DINOv2 via HuggingFace, construa um índice flat no FAISS e reporte recall@{1, 5, 10} contra as mesmas imagens como consultas (deve ser 1,0) e contra uma divisão retida usando rótulos do ImageNet como verdade fundamental.
Termos-chave
| Termo | O que as pessoas dizem | O que realmente significa |
|---|---|---|
| Aprendizado de métrica | "Moldar o espaço" | Treinar um encoder para que distâncias no seu espaço de saída reflitam uma similaridade-alvo |
| Perda triplet | "Puxar e empurrar" | L = max(0, d(a, p) - d(a, n) + margin); a perda canônica de aprendizado de métrica |
| Mineração semi-difícil | "Negativos úteis" | Negativos mais longe da âncora que o positivo mas dentro da margem; empiricamente os mais informativos |
| Perda baseada em proxy | "Protótipos de classe" | Um proxy aprendido por classe; cross-entropy sobre similaridade-com-proxies; sem mineração de pares |
| Recall@K | "Taxa de acerto no Top-K" | Fração de consultas com pelo menos um resultado correto nos K primeiros |
| Recuperação de instância | "Encontre esta coisa exata" | Correspondência fina; features prontas para uso geralmente têm desempenho inferior |
| FAISS | "A biblioteca de NN" | A biblioteca de vizinhos mais próximos do Facebook; suporta índices exatos e aproximados |
| HNSW | "Índice de grafo" | Hierarchical navigable small world; NN aproximado rápido com pouco overhead de memória |
Leitura Adicional
- FaceNet: A Unified Embedding for Face Recognition (Schroff et al., 2015) — o artigo da perda triplet / mineração semi-difícil
- In Defense of the Triplet Loss for Person Re-Identification (Hermans et al., 2017) — guia prático para fine-tuning com triplet
- Documentação do FAISS — cada índice, cada trade-off
- SMoT: Metric Learning Taxonomy (Kim et al., 2021) — survey de perdas modernas e suas conexões