O problema como ele aparece, não como ele é descrito
O cenário é o mais comum que existe em visão computacional aplicada. Há um dataset organizado em pastas, uma por classe: carro/, moto/, cachorro/, gato/. Pode ser marca, categoria de produto, tipo de defeito, não importa. A anotação é contínua: toda semana entram imagens novas, vindas de pessoas diferentes, de fontes diferentes, com critérios que mudam um pouco a cada mão que passa. E a pergunta que chega é sempre a mesma: "as classes estão desbalanceadas, o que a gente faz?"
A resposta que eu dou primeiro é que desbalanceamento é o sintoma visível de três problemas que se parecem, e que exigem ferramentas diferentes. Tratar os três como um só é a origem de quase todo dataset que "foi balanceado" e continuou treinando mal.
- Cópias exatas. O mesmo arquivo subido duas vezes. A mesma foto exportada por dois anotadores. Isso não é dado, é contagem inflada.
- Quase-cópias. A mesma imagem redimensionada, recomprimida, com marca d'água, com um recorte de 2%. E a versão mais traiçoeira: a rajada, trinta quadros do mesmo vídeo, ou dez cliques seguidos da mesma câmera apontada para a mesma moto. Para o modelo, trinta quadros de uma rajada valem perto de um exemplo.
- Desbalanceamento genuíno. Depois de tirar as cópias, uma classe ainda tem quatro vezes mais exemplos reais que a outra, porque o mundo produz mais fotos de carro do que de moto, ou porque a equipe que anota carro é maior.
A ordem importa. Se você balancear antes de deduplicar, você balanceia a sujeira. Uma classe com 10 mil imagens em que 4 mil são rajadas de vídeo não tem 10 mil exemplos, tem talvez 6.500. Subamostrar a classe grande para igualar a pequena, sem saber disso, pode jogar fora exatamente os exemplos únicos e preservar as cópias. E sobreamostrar a classe pequena, se ela também tem cópias, é multiplicar a mesma foto.
O pior efeito das cópias não é no treino, é na validação. Se a mesma imagem, ou um quadro vizinho dela, cai num lado do split e a cópia cai no outro, a acurácia de validação sobe sem que o modelo tenha aprendido nada. Ele memorizou. Já vi números de validação que caíram de forma expressiva depois de uma deduplicação feita entre os splits, não só dentro de cada um. O número anterior nunca tinha sido verdade. Por isso a deduplicação roda antes do split, e roda sobre o conjunto inteiro, nunca pasta por pasta.
Por que "manual" não é uma opção, e por que "só pesar a loss" também não
A tentação de resolver olhando é grande quando são 2 mil imagens. Com 200 mil, a revisão humana vira um gargalo, e pior, vira um gargalo inconsistente: a pessoa que limpa a pasta na segunda-feira usa um critério, a de sexta usa outro. A curadoria tem que ser um processo reproduzível que roda a cada ingestão, com saída auditável. Se eu rodar duas vezes sobre o mesmo dataset, tem que sair o mesmo resultado.
A outra tentação é não mexer nos dados e compensar no modelo: peso por classe na função de perda, focal loss, amostrador balanceado. São ferramentas boas e vou voltar a elas, mas elas tratam o terceiro problema, não os dois primeiros. Peso por classe não sabe que 40% de uma classe é a mesma imagem. Ele só sabe que a classe é grande, e vai diminuir o peso de cada exemplo dela, inclusive dos únicos, enquanto as cópias continuam puxando o gradiente na mesma direção, só que com peso menor. A redundância continua lá, redistribuída.
Então a proposta é uma pipeline em camadas, na mesma lógica que uso em qualquer sistema de classificação em escala: cada camada é mais cara que a anterior e só recebe o que a anterior não conseguiu resolver. A primeira é quase de graça e não erra. A última é cara e envolve julgamento.
Camada 1: igualdade exata com hash, e a pergunta "igual em quê?"
O começo certo, e é por onde eu começo em qualquer dataset, é atacar a igualdade com hash. Calcular um resumo criptográfico de cada arquivo, agrupar pelos resumos, e tudo que colidir é cópia. É rápido, é determinístico, e não tem falso positivo que valha a pena discutir. Em um dataset de centenas de milhares de imagens isso roda em minutos, limitado pelo disco, não pela CPU.
A primeira armadilha aparece logo: igual em quê? Hash do arquivo compara bytes. Duas exportações da mesma foto, uma com os metadados EXIF e outra sem, são arquivos diferentes e pixels idênticos. O mesmo JPEG salvo com outro nome e re-gravado por uma ferramenta que reescreve o cabeçalho também. Se o hash é dos bytes do arquivo, essas cópias passam.
A alternativa é decodificar a imagem, normalizar (converter para RGB, descartar canal alfa, descartar metadados) e calcular o hash do buffer de pixels. Isso captura as cópias com metadado diferente, e tem um custo: decodificar JPEG é uma ou duas ordens de grandeza mais caro que ler bytes. Na prática eu faço os dois, em sequência: o hash de bytes primeiro, porque é quase gratuito e resolve o grosso, e o hash de pixels como segunda passada sobre o que sobrou. Como a segunda passada já precisa decodificar a imagem, é nela que eu aproveito para calcular tudo o que a camada seguinte vai precisar.
Hash dos bytes do arquivo
- Custo: ler o disco, só isso
- Pega: upload repetido, cópia de pasta
- Deixa passar: EXIF diferente, re-gravação, formato diferente
Hash dos pixels decodificados
- Custo: decodificar cada imagem
- Pega: tudo o que o anterior pega, mais metadado e formato
- Deixa passar: qualquer recompressão com perda, qualquer resize
Duas decisões de projeto nessa camada valem mais que o algoritmo. A primeira: nada é apagado. A cópia vai para uma pasta de quarentena, espelhando a estrutura original, e um manifesto registra quem ficou como canônico, quem saiu, e por qual regra. Apagar é irreversível e, mais importante, torna o processo impossível de auditar. Mover é barato, e permite que alguém pergunte daqui a seis meses "por que essa imagem sumiu?" e receba uma resposta.
A segunda: qual cópia fica? Parece detalhe e não é. Se a regra for "a primeira que o sistema encontrar", o resultado depende da ordem de listagem do disco, e duas execuções podem escolher canônicos diferentes. A regra precisa ser determinística e explicável: a de maior resolução, depois a de maior tamanho em bytes (menos recompressão), depois o nome lexicograficamente menor como desempate. Qualquer regra fixa serve, desde que seja fixa.
A mesma imagem, com o mesmo hash, em carro/ e em moto/ não é um problema de deduplicação. É um conflito de rótulo: alguém errou, e o sistema não tem como saber quem. Essa é a primeira fila de revisão humana da pipeline, e é pequena, porque colisão exata entre classes é rara. É também a fila mais valiosa por imagem, porque cada item dela é um erro de anotação confirmado. Tratar isso como duplicata e escolher um lado automaticamente é inventar um rótulo.
Camada 2: quase-igual com hash perceptual
Depois dos hashes exatos, sobram as versões redimensionadas, recomprimidas, com marca d'água pequena, com recorte de borda. Aqui entra o hash perceptual, que é uma família de funções desenhadas para que imagens visualmente parecidas gerem hashes próximos, não iguais. O mais usado, o pHash, faz algo simples: reduz a imagem para 32×32 em tons de cinza, aplica uma transformada DCT, pega o bloco 8×8 de frequências mais baixas, e marca 1 em cada coeficiente acima da mediana. Saem 64 bits que descrevem a estrutura grosseira da imagem. Duas imagens se comparam pela distância de Hamming: quantos dos 64 bits diferem.
Os limiares que se usam na prática: distância 0 é a mesma imagem; até 5, quase certamente a mesma imagem com alguma transformação; de 6 a 10, zona cinza; acima disso, imagens diferentes. Esses números não são lei, são o ponto de partida que você calibra olhando uma amostra de pares em cada faixa, e é um dos poucos lugares da pipeline em que uma hora de olho humano no começo economiza semanas depois.
O que o hash perceptual pega: resize, mudança de qualidade de compressão, conversão de formato, ajustes leves de brilho, marca d'água pequena, recorte de poucos por cento. O que ele deixa passar, e é bom saber antes de confiar nele: espelhamento horizontal (os bits mudam muito), rotação, recorte agressivo, e qualquer mudança de enquadramento. Ele também tem um ponto fraco de falso positivo: imagens com pouca estrutura de baixa frequência, como um objeto pequeno sobre fundo branco ou céu, tendem a ter hashes parecidos entre si mesmo sendo objetos diferentes. Se o seu dataset é de produtos fotografados em estúdio, essa fraqueza vai aparecer, e o limiar precisa ser mais apertado do que num dataset de cenas de rua.
Vale usar mais de uma função. O dHash (diferença entre pixels vizinhos) é mais sensível a gradientes, o aHash (média) é mais tolerante, o pHash fica no meio. Exigir que dois deles concordem reduz falso positivo ao custo de algum falso negativo. Eu prefiro pagar esse custo nessa camada, porque o que escapar aqui ainda tem a camada seguinte; o que for removido errado aqui não volta sozinho.
O custo de comparar todo mundo com todo mundo
A comparação de hashes é par a par, e isso cresce com o quadrado do número de imagens. Antes de se assustar, vale fazer a conta honesta. Hamming entre dois inteiros de 64 bits é um XOR e um popcount, e um processador faz bilhões disso por segundo. Para 50 mil imagens numa classe, são 1,25 bilhão de pares, e isso em NumPy vetorizado, em blocos, leva minutos, não horas. O problema quadrático só vira problema de verdade na casa do milhão de imagens.
Quando vira, a saída é o princípio da casa dos pombos: se dois hashes de 64 bits diferem em no máximo d bits, e você divide os 64 bits em d+1 blocos, pelo menos um bloco é idêntico nos dois. Então você indexa cada imagem por cada um dos seus blocos, e só compara pares que colidem em algum bloco. Para d = 5, são 6 blocos de 10 ou 11 bits, e o número de comparações despenca. Uma árvore BK faz o mesmo papel com outra estrutura. Nenhuma das duas é difícil de implementar, e as duas são overkill abaixo de algumas centenas de milhares de imagens.
# a parte que importa cabe em poucas linhas
def hamming_blocos(hashes, d=5):
blocos = d + 1
indice = defaultdict(list)
for i, h in enumerate(hashes):
for b in range(blocos):
indice[(b, fatia(h, b, blocos))].append(i)
for grupo in indice.values():
for i, j in combinations(grupo, 2):
if popcount(hashes[i] ^ hashes[j]) <= d:
yield i, j
Camada 3: semelhança semântica, onde "parecido" deixa de significar "redundante"
Hash perceptual olha pixels. Ele não sabe que trinta quadros de um vídeo da mesma moto, com a câmera se movendo, são a mesma moto. Cada quadro tem enquadramento um pouco diferente, o hash muda, e a rajada inteira passa como trinta exemplos. Para enxergar isso é preciso um embedding: passar cada imagem por um codificador visual, obter um vetor, e medir similaridade de cosseno entre vetores. Dois quadros vizinhos da mesma rajada ficam com similaridade altíssima, e um grafo de vizinhos mais próximos revela o grupo inteiro.
É aqui que a conversa muda de natureza, e quero ser claro sobre isso. Nas camadas anteriores, "parecido" era sinônimo de "redundante". Nesta, não é mais. Trinta quadros de uma rajada são redundantes. Dez fotos da mesma raça de cachorro, em dez quintais diferentes, são parecidas e são exatamente a diversidade que você quer. O codificador não distingue as duas situações. Ele devolve um número. Quem decide o que o número significa é você, e essa decisão é de negócio disfarçada de hiperparâmetro.
Que codificador, e por quê isso muda a resposta
Num outro texto deste blog, sobre o Stratosphere, conto como o DINOv2 fracassou para reconhecer marcas porque ele mede semelhança de superfície: o painel, a luz, a moldura, mais do que o objeto. Para aquele problema foi um defeito. Para deduplicação, é uma qualidade. Quando eu quero achar quadros da mesma rajada, quero justamente um codificador sensível à superfície: mesma luz, mesmo fundo, mesma textura. Um codificador semântico como o SigLIP ou o CLIP, treinado para casar imagem com legenda, vai aproximar "cachorro no quintal" de qualquer outro "cachorro no quintal", e isso agrupa diversidade legítima junto com redundância.
A consequência prática é que a camada 3 pode ter duas passadas com propósitos distintos. Uma com codificador auto-supervisionado, limiar alto (acima de 0,95 de cosseno, calibrado), para colapsar rajadas. Outra com codificador semântico, limiar mais baixo, não para remover nada, e sim para mapear a diversidade da classe, que é o que a camada 4 vai precisar. Misturar as duas num só número é o erro mais comum que vejo.
Qual exemplar fica: o "mais plausível"
Depois de agrupar, cada grupo precisa de um representante. A escolha ingênua é o medoide, o item mais central do grupo. Ela tem um problema: o mais central de uma rajada não é necessariamente o mais nítido nem o melhor exposto. Eu combino dois critérios, em ordem: primeiro qualidade técnica, que é barata de medir (variância do Laplaciano como proxy de nitidez, resolução, histograma não estourado), e depois centralidade como desempate. O resultado é que de cada rajada sobra o quadro mais nítido entre os que são representativos, e não o quadro borrado que por acaso estava no meio.
Toda regra que prefere o exemplo "típico" puxa o dataset para o centro da classe. E o centro da classe é a parte que o modelo já aprende fácil. Os exemplos que ensinam a fronteira são os atípicos: a moto vista de cima, o gato que parece cachorro, o carro meio coberto por uma lona. Uma deduplicação agressiva, com limiar baixo demais, remove justamente esses, porque eles têm poucos vizinhos próximos e são fáceis de confundir com ruído. A regra que adoto: colapsar grupos densos, nunca podar caudas. Um exemplo sem vizinhos próximos é, por definição, único, e único é o que a gente está tentando preservar. Se ele é lixo, isso é problema de outra fila: a de qualidade, não a de duplicata.
E há um efeito colateral bom que vale explorar: os vizinhos mais próximos entre classes diferentes. Se uma imagem em gato/ tem como vizinho mais próximo, com similaridade muito alta, uma imagem em cachorro/, uma das duas está provavelmente errada. Não dá para resolver automaticamente, mas dá para ranquear: as poucas centenas de pares mais suspeitos vão para revisão humana, e essa fila costuma ter uma taxa de erro real altíssima. É a mesma ideia da fila de órfãos do Stratosphere: o sistema não corrige, mas sabe apontar onde olhar.
Camada 4: agora sim, balancear
Só depois das três camadas o desbalanceamento é real, e aí dá para discutir o que fazer com ele. As opções conhecidas, com o que cada uma custa:
| Estratégia | O que faz bem | O que custa |
|---|---|---|
| Subamostrar a maior | Rápido, reduz tempo de treino | Joga fora dado real. Aleatório, pode remover os únicos e manter os típicos |
| Subamostrar com diversidade | Mantém a cobertura da classe com menos imagens | Precisa dos embeddings semânticos da camada 3; mais lento de escolher |
| Sobreamostrar a menor | Simples de implementar | Repetir arquivo é fabricar duplicata; só faz sentido com augmentation real |
| Peso por classe na perda | Não toca nos dados; fácil de ajustar | Pode gerar gradientes instáveis em classes raríssimas; não resolve redundância |
| Focal loss | Foca nos exemplos difíceis, não só nas classes raras | Mais um hiperparâmetro; interage mal com rótulos ruidosos |
A que eu prefiro para a classe grande é a subamostragem com diversidade, feita com um algoritmo simples de cobertura, o farthest point sampling: começa de um exemplo, escolhe em seguida o que está mais longe de todos os já escolhidos, repete até atingir o teto por classe. O resultado é um subconjunto que cobre o espaço da classe inteiro com poucos pontos, em vez de concentrar onde há mais foto. É o oposto exato do sorteio aleatório, que amostra proporcionalmente à densidade, ou seja, pega mais de onde já tem mais.
Para a classe pequena, não repito arquivo. Uso augmentation em tempo de treino, com um amostrador que equaliza a probabilidade de cada classe por época, e deixo o peso na perda como ajuste fino. E registro isso: o manifesto do dataset diz quantos exemplos reais cada classe tem, para que ninguém leia o número de iterações como se fosse número de imagens.
Se em produção chegam quatro carros para cada moto, um modelo treinado em 50/50 vai superestimar moto, e a calibração das probabilidades vai sair torta. O balanceamento serve para o modelo aprender a classe rara, não para fingir que ela é frequente. Duas regras práticas: o conjunto de validação nunca é balanceado, ele reflete a distribuição que o sistema vai enfrentar; e o relatório de métricas é sempre por classe, nunca só a acurácia global, que num dataset 80/20 é enganosa por construção.
A arquitetura, e o que nela é negociável
Juntando tudo, a pipeline é uma sequência de estágios, cada um lendo um manifesto e escrevendo outro, sem destruir nada no caminho:
O manifesto é o produto mais importante. Cada imagem tem uma linha dizendo onde foi parar e por qual regra. Isso resolve a auditoria, permite reverter uma decisão sem reprocessar, e, principalmente, transforma a pipeline em algo que se monitora. Se numa ingestão a taxa de remoção da classe moto/ salta de 8% para 40%, não é uma classe que ficou suja de repente. É alguém que subiu um vídeo inteiro como imagens, ou um bug no script de exportação. O relatório por estágio acusa isso antes do treino, e esse é o tipo de alarme que ninguém configura e todo mundo gostaria de ter tido.
O que é negociável nessa arquitetura: os limiares, os codificadores, o algoritmo de indexação, a ordem entre perceptual e embedding quando o volume é pequeno. O que não é: deduplicar antes do split e sobre o conjunto todo, nunca apagar, regra de canônico determinística, e revisão humana nas filas em que a máquina só consegue suspeitar.
Onde a solução falha, dita de frente
- Os limiares se desatualizam. Um dataset que começou com fotos de rua e passou a receber fotos de estúdio muda a distribuição de distâncias. O limiar que era seguro vira agressivo. Isso exige reamostrar e olhar pares de tempos em tempos, e não há como automatizar a totalidade disso.
- Embedding custa GPU. Codificar 500 mil imagens é algo entre dezenas de minutos e horas, dependendo do modelo. É aceitável como processo em lote, e inaceitável se alguém espera que a ingestão seja instantânea. A decisão de rodar a camada 3 a cada ingestão ou uma vez por semana é de custo, e precisa ser dita como tal.
- Hash perceptual erra em fundo liso. Já falei, mas repito porque é onde o falso positivo mora. Em dataset de catálogo, apertar o limiar ou exigir concordância de duas funções.
- Colapsar rajadas pode esconder variação temporal legítima. Se a classe é "defeito que evolui" e a sequência importa, a rajada é o dado. A pipeline precisa de um modo por classe, ou de uma lista de exceções, e isso é mais uma coisa a manter.
- A fila de revisão humana precisa de dono. Fila sem dono vira cemitério. Se ninguém revisa os conflitos de rótulo, eles ficam de fora do treino para sempre, e as imagens mais informativas do dataset são justamente essas.
- O manifesto vira um sistema. Com o tempo ele precisa de versionamento, de diff entre versões, de integração com a ferramenta de anotação. É um custo que começa pequeno e cresce. Vale a pena, mas é bom entrar sabendo.
Começaria pelo manifesto e pela quarentena, antes de qualquer algoritmo de similaridade. A versão que começa "apagando duplicatas com um script" funciona na primeira semana e vira dívida na segunda, quando alguém pergunta onde foi parar uma imagem e a resposta é "o script removeu". Com o manifesto de pé, cada camada nova é um estágio a mais que lê e escreve linhas. Sem ele, cada camada nova é um script a mais que ninguém sabe se já rodou.
Resumo da conversa
- Desbalanceamento é sintoma. Por baixo há três problemas: cópia exata, quase-cópia e desproporção real. Cada um tem a sua camada, e a ordem é essa.
- Hash de bytes primeiro, hash de pixels depois. Nada é apagado, tudo vai para quarentena com uma regra registrada e um canônico escolhido de forma determinística.
- A mesma imagem em duas classes não é duplicata, é erro de rótulo. Vai para humano.
- Hash perceptual pega resize e recompressão, deixa passar espelhamento e recorte forte, e erra em fundo liso. Calibrar o limiar olhando pares.
- Embedding enxerga rajadas, mas "parecido" já não é "redundante". O codificador escolhido define o que "parecido" significa. Colapsar grupos densos, nunca podar caudas.
- Balancear só no fim, e com diversidade, não com sorteio. Validação fica desbalanceada de propósito. Métricas por classe.
- O manifesto é o produto. Ele faz a pipeline ser auditável, reversível e monitorável, e é por ele que você descobre uma ingestão errada antes do treino.
Igor Nascimento