Logo NascimentoIgor Nascimento / Blog
Dados

O dataset que cresce torto: deduplicar, podar e balancear sem abrir pasta por pasta

Um dataset de imagens organizado por classe nunca cresce em linha reta. Uma classe engorda, outra fica para trás, e dentro de cada pasta entram cópias, quase-cópias e rajadas do mesmo vídeo. Uma conversa sobre separar três problemas que parecem um só, sobre o que cada ferramenta de similaridade enxerga e deixa passar, e sobre por que a decisão mais importante da pipeline não é técnica.

DadosVisão computacional17 min de leituraIgor Nascimento

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.

  1. Cópias exatas. O mesmo arquivo subido duas vezes. A mesma foto exportada por dois anotadores. Isso não é dado, é contagem inflada.
  2. 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.
  3. 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 dano que ninguém vê na métrica de treino

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.

O caso que não é duplicata

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.

O trade-off que mais me preocupa nessa camada

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égiaO que faz bemO que custa
Subamostrar a maiorRápido, reduz tempo de treinoJoga fora dado real. Aleatório, pode remover os únicos e manter os típicos
Subamostrar com diversidadeMantém a cobertura da classe com menos imagensPrecisa dos embeddings semânticos da camada 3; mais lento de escolher
Sobreamostrar a menorSimples de implementarRepetir arquivo é fabricar duplicata; só faz sentido com augmentation real
Peso por classe na perdaNão toca nos dados; fácil de ajustarPode gerar gradientes instáveis em classes raríssimas; não resolve redundância
Focal lossFoca nos exemplos difíceis, não só nas classes rarasMais 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.

Balancear nem sempre é o objetivo

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:

dataset/ ├── raw/ # o que chegou, por classe, intocado ├── quarantine/ │ ├── exact/ # cópias por hash de bytes ou de pixels │ ├── near/ # quase-cópias por hash perceptual │ ├── burst/ # rajadas colapsadas por embedding │ └── review/ # conflitos de rótulo e pares suspeitos entre classes ├── curated/ # o que sobrou, por classe └── manifests/ ├── 2026-10-22.jsonl # uma linha por imagem: hash, phash, embedding_id, destino, regra, canônico └── report.md # quanto saiu de cada classe, por estágio

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

O que eu faria diferente da primeira vez

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