FAISS: Pesquise rapidamente rostos e clones em dados multimilionários





Certa vez, na véspera de uma conferência de clientes realizada anualmente pelo grupo DAN, ponderamos que coisas interessantes poderiam ser pensadas para que nossos parceiros e clientes tivessem impressões e lembranças agradáveis ​​do evento. Decidimos analisar um arquivo de milhares de fotos desta conferência e várias anteriores (e havia 18 delas naquela época): uma pessoa nos envia sua foto e, em alguns segundos, enviamos a ele uma seleção de fotos de nossos arquivos por vários anos.



Nós não inventamos uma bicicleta, pegamos a conhecida biblioteca dlib e recebemos incorporações (representações vetoriais) de cada pessoa. 



Adicionamos um bot do Telegram por conveniência, e tudo foi ótimo. Do ponto de vista dos algoritmos de reconhecimento de face, tudo funcionou bem, mas a conferência terminou e eu não queria me separar de tecnologias testadas e comprovadas. Queríamos passar de milhares de pessoas a centenas de milhões, mas não tínhamos uma tarefa comercial específica. Depois de algum tempo, nossos colegas tiveram uma tarefa que exigia trabalhar com quantidades tão grandes de dados.



A questão era escrever um sistema inteligente de monitoramento de bots no Instagram. Aqui nosso pensamento deu origem a uma abordagem simples e complexa:



Maneira simples: consideramos todas as contas que têm muito mais assinaturas que assinantes, nenhum avatar, nome completo não é preenchido etc. Como resultado, temos uma multidão incompreensível de contas meio mortas.



O caminho difícil: como os robôs modernos se tornaram muito mais inteligentes e agora publicam, dormem e até escrevem conteúdo, surge a pergunta: como capturá-los? Em primeiro lugar, monitore de perto os amigos, pois eles também são bots e acompanhe fotos duplicadas. Em segundo lugar, raramente qualquer bot sabe como gerar suas próprias imagens (embora isso seja possível), o que significa que fotos duplicadas de pessoas em contas diferentes no Instagram são um bom gatilho para encontrar uma rede de bots.



Qual é o próximo?



Se um caminho simples é bastante previsível e rapidamente dá alguns resultados, então um caminho difícil é difícil justamente porque para implementá-lo, teremos que vetorizar e indexar volumes incrivelmente grandes de fotografias para comparações subsequentes de similaridades - milhões e até bilhões. Como colocá-lo em prática? Afinal, surgem problemas técnicos:



  1. Velocidade e precisão da pesquisa
  2. Espaço em disco usado pelos dados
  3. O tamanho da memória RAM usada. 


Se houvesse apenas algumas fotos, pelo menos não mais que dez mil, poderíamos nos limitar a soluções simples com agrupamento de vetores, mas trabalhar com grandes volumes de vetores e encontrar os vizinhos mais próximos de um determinado vetor, são necessários algoritmos complexos e otimizados.



Existem tecnologias conhecidas e comprovadas, como Annoy, FAISS, HNSW. Rápido HNSW vizinho lookup algoritmo disponível no nmslib e bibliotecas hnswlib, mostra resultados de última geração na CPU, que podem ser vistos nos mesmos benchmarks. Mas nós o cortamos imediatamente, pois não estamos satisfeitos com a quantidade de memória usada ao trabalhar com grandes quantidades de dados. Começamos a escolher entre Annoy e FAISS e, no final, escolhemos FAISS por conveniência, menos uso de memória, uso potencial na GPU e benchmarks de desempenho (você pode ver, por exemplo, aqui ). A propósito, no FAISS, o algoritmo HNSW é implementado como uma opção.



O que é FAISS?



A Pesquisa de similaridade de pesquisa do AI do Facebook é um desenvolvimento da equipe de Pesquisa do AI do Facebook para encontrar rapidamente vizinhos próximos e agrupar no espaço vetorial. A alta velocidade de pesquisa permite trabalhar com dados muito grandes - até vários bilhões de vetores.



A principal vantagem do FAISS são os resultados mais avançados da GPU, enquanto sua implementação na CPU perde um pouco para hnsw (nmslib). Queríamos poder pesquisar na CPU e na GPU. Além disso, o FAISS é otimizado em termos de uso de memória e pesquisa em grandes lotes.



O



FAISS de origem permite encontrar rapidamente os k vetores mais próximos para um determinado vetor x. Mas como essa pesquisa funciona sob o capô?



Índices



O principal conceito do FAISS é índice e, em essência, é apenas uma coleção de parâmetros e vetores. Conjuntos de parâmetros são completamente diferentes e dependem das necessidades do usuário. Os vetores podem permanecer inalterados, mas podem ser reconstruídos. Alguns índices estão disponíveis para o trabalho imediatamente após a adição de vetores e alguns requerem treinamento preliminar. Os nomes de vetores são armazenados no índice: na numeração de 0 a n ou como um número que se encaixa no tipo Int64.



O primeiro índice, e o mais simples que usamos na conferência, é o Flat . Ele armazena apenas todos os vetores em si, e a pesquisa de um determinado vetor é realizada por pesquisa exaustiva, portanto, não há necessidade de treiná-lo (mas mais sobre o treinamento abaixo). Com uma pequena quantidade de dados, um índice tão simples pode cobrir totalmente as necessidades da pesquisa.



Exemplo:



import numpy as np
dim = 512  #     512
nb = 10000  #    
nq = 5 #      
np.random.seed(228)
vectors = np.random.random((nb, dim)).astype('float32')
query = np.random.random((nq, dim)).astype('float32')


Crie um índice simples e adicione vetores sem treinamento:



import faiss
index = faiss.IndexFlatL2(dim)
print(index.ntotal)  #   
index.add(vectors)
print(index.ntotal)  #    10 000 


Agora encontramos 7 vizinhos mais próximos para os cinco primeiros vetores de vetores:



topn = 7
D, I = index.search(vectors[:5], topn)  #  : Distances, Indices
print(I)
print(D)


Resultado
[[0 5662 6778 7738 6931 7809 7184]
 [1 5831 8039 2150 5426 4569 6325]
 [2 7348 2476 2048 5091 6322 3617]
 [3  791 3173 6323 8374 7273 5842]
 [4 6236 7548  746 6144 3906 5455]]

[[ 0.  71.53578  72.18823  72.74326  73.2243   73.333244 73.73317 ]
 [ 0.  67.604805 68.494774 68.84221  71.839905 72.084335 72.10817 ]
 [ 0.  66.717865 67.72709  69.63666  70.35903  70.933304 71.03237 ]
 [ 0.  68.26415  68.320595 68.82381  68.86328  69.12087  69.55179 ]
 [ 0.  72.03398  72.32417  73.00308  73.13054  73.76181  73.81281 ]]




Vemos que os vizinhos mais próximos com uma distância de 0 são os próprios vetores, o resto é variado pelo aumento da distância. Vamos procurar nossos vetores da consulta:



D, I = index.search(query, topn) 
print(I)
print(D)


Resultado
[[2467 2479 7260 6199 8640 2676 1767]
 [2623 8313 1500 7840 5031   52 6455]
 [1756 2405 1251 4136  812 6536  307]
 [3409 2930  539 8354 9573 6901 5692]
 [8032 4271 7761 6305 8929 4137 6480]]

[[73.14189  73.654526 73.89804  74.05615  74.11058  74.13567  74.443436]
 [71.830215 72.33813  72.973885 73.08897  73.27939  73.56996  73.72397 ]
 [67.49588  69.95635  70.88528  71.08078  71.715965 71.76285  72.1091  ]
 [69.11357  69.30089  70.83269  71.05977  71.3577   71.62457  71.72549 ]
 [69.46417  69.66577  70.47629  70.54611  70.57645  70.95326  71.032005]]




Agora, as distâncias na primeira coluna dos resultados não são zero, pois os vetores da consulta não estão no índice.



O índice pode ser salvo no disco e, em seguida, carregado no disco:



faiss.write_index(index, "flat.index")
index = faiss.read_index("flat.index")


Parece que tudo é elementar! Algumas linhas de código - e já temos uma estrutura para pesquisar vetores de alta dimensão. Mas esse índice com apenas dez milhões de vetores da dimensão 512 pesará cerca de 20 GB e consumirá a mesma quantidade de RAM ao usá-lo.



No projeto da conferência, usamos apenas uma abordagem tão básica com um índice plano, tudo foi ótimo devido à quantidade relativamente pequena de dados, mas agora estamos falando de dezenas e centenas de milhões de vetores de alta dimensão!



Acelere sua pesquisa com listas invertidas





Fonte



O principal e mais interessante recurso do FAISS é o índice de fertilização in vitro ouíndice de arquivo invertido . A idéia dos arquivos invertidos é lacônica e lindamente explicada nos dedos :



vamos imaginar um exército gigantesco, composto pelos guerreiros mais heterogêneos, numerando, digamos, 1.000.000 de pessoas. Será impossível comandar todo o exército de uma só vez. Como é habitual em assuntos militares, precisamos dividir nosso exército em subunidades. Vamos dividir em1000000=1000partes aproximadamente iguais, escolhendo para o papel de comandantes um representante de cada unidade. E tentaremos enviar os caracteres mais semelhantes, origem, dados físicos etc. soldados em uma unidade, e selecionamos o comandante para que ele represente sua unidade com a maior precisão possível - ele era alguém "médio". Como resultado, nossa tarefa foi reduzida do comando de um milhão de soldados para o comando das milésimas unidades através de seus comandantes, e temos uma excelente ideia da composição de nosso exército, pois sabemos o que são comandantes.



Esta é a idéia por trás do índice de fertilização in vitro: vamos agrupar um grande conjunto de vetores peça por peça usando o algoritmo k-means, colocando correspondência em cada parte um centróide, é um vetor que é o centro escolhido para um determinado cluster. A busca será realizada através da distância mínima até o centróide, e somente então procuraremos as distâncias mínimas entre os vetores no cluster que correspondem ao centróide especificado. Tomando k igualnOnde n - o número de vetores no índice, obteremos uma pesquisa ideal em dois níveis - primeiro entre n centróide, então entre nvetores em cada cluster. A pesquisa, comparada à pesquisa exaustiva, é acelerada várias vezes, o que resolve um dos nossos problemas ao trabalhar com muitos milhões de vetores.





O espaço vetorial é dividido pelo método k-means em k clusters. Cada conjunto é atribuído um centróide.



Código Exemplo:



dim = 512
k = 1000  #  “”
quantiser = faiss.IndexFlatL2(dim) 
index = faiss.IndexIVFFlat(quantiser, dim, k)
vectors = np.random.random((1000000, dim)).astype('float32')  # 1 000 000 “”


Ou você pode anotá-la com muito mais elegância, usando a prática ferramenta FAISS para criar um índice:



index = faiss.index_factory(dim, “IVF1000,Flat”)
 :
print(index.is_trained)   # False.
index.train(vectors)  # Train    
 
#  ,      ,      :
print(index.is_trained)  # True
print(index.ntotal)   # 0
index.add(vectors)
print(index.ntotal)   # 1000000


Depois de examinar esse tipo de índice após o Flat, resolvemos um de nossos possíveis problemas - a velocidade da pesquisa, que se torna várias vezes menor em comparação à pesquisa exaustiva.



D, I = index.search(query, topn) 
print(I)
print(D)


Resultado
[[19898 533106 641838 681301 602835 439794 331951]
 [654803 472683 538572 126357 288292 835974 308846]
 [588393 979151 708282 829598  50812 721369 944102]
 [796762 121483 432837 679921 691038 169755 701540]
 [980500 435793 906182 893115 439104 298988 676091]]

[[69.88127  71.64444  72.4655   72.54283  72.66737  72.71834  72.83057]
 [72.17552  72.28832  72.315926 72.43405  72.53974  72.664055 72.69495]
 [67.262115 69.46998  70.08826  70.41119  70.57278  70.62283  71.42067]
 [71.293045 71.6647   71.686615 71.915405 72.219505 72.28943  72.29849]
 [73.27072  73.96091  74.034706 74.062515 74.24464  74.51218  74.609695]]




Mas existe um "mas" - a precisão da pesquisa, bem como a velocidade, dependerão do número de clusters visitados, que podem ser configurados usando o parâmetro nprobe:



print(index.nprobe)  # 1 –           
index.nprobe = 16  #   -16    top-n  
D, I = index.search(query, topn) 
print(I)
print(D)


Resultado
[[ 28707 811973  12310 391153 574413  19898 552495]
 [540075 339549 884060 117178 878374 605968 201291]
 [588393 235712 123724 104489 277182 656948 662450]
 [983754 604268  54894 625338 199198  70698  73403]
 [862753 523459 766586 379550 324411 654206 871241]]

[[67.365585 67.38003  68.17187  68.4904   68.63618  69.88127  70.3822]
 [65.63759  67.67015  68.18429  68.45782  68.68973  68.82755  69.05]
 [67.262115 68.735535 68.83473  68.88733  68.95465  69.11365  69.33717]
 [67.32007  68.544685 68.60204  68.60275  68.68633  68.933334 69.17106]
 [70.573326 70.730286 70.78615  70.85502  71.467674 71.59512  71.909836]]




Como você pode ver, depois de aumentar o nprobe, temos resultados completamente diferentes, o topo das distâncias mais curtas em D se tornou melhor.



Você pode usar nprobe igual ao número de centróides no índice; isso será equivalente à pesquisa por pesquisa exaustiva; a precisão será maior, mas a velocidade da pesquisa diminuirá visivelmente.



Pesquisando no disco - Listas invertidas no disco



Ótimo, resolvemos o primeiro problema, agora obtemos uma velocidade de pesquisa aceitável em dezenas de milhões de vetores! Mas tudo isso é inútil, contanto que nosso enorme índice não se ajuste à RAM.



Especificamente para a nossa tarefa, a principal vantagem do FAISS é a capacidade de armazenar índices de FIV de listas invertidas no disco, carregando apenas metadados na RAM.



Como criamos um índice desse tipo: treinamos o indexIVF com os parâmetros necessários na quantidade máxima possível de dados que entra na memória, depois adicionamos vetores ao índice treinado em partes (tendo sido treinados e não apenas) e gravamos o índice de cada uma das partes no disco.



index = faiss.index_factory(512, “,IVF65536, Flat”, faiss.METRIC_L2)


O treinamento do índice GPU é realizado da seguinte maneira:



res = faiss.StandardGpuResources()
index_ivf = faiss.extract_index_ivf(index)
index_flat = faiss.IndexFlatL2(512)
clustering_index = faiss.index_cpu_to_gpu(res, 0, index_flat)  #  0 –  GPU
index_ivf.clustering_index = clustering_index


faiss.index_cpu_to_gpu (res, 0, index_flat) pode ser substituído por faiss.index_cpu_to_all_gpus (index_flat) para usar todas as GPUs juntas.



É altamente desejável que a amostra de treinamento seja o mais representativa possível e tenha uma distribuição uniforme, para que possamos compor o conjunto de dados de treinamento antecipadamente a partir do número necessário de vetores, selecionando-os aleatoriamente no conjunto de dados inteiro.



train_vectors = ...  #     
index.train(train_vectors)

#    ,   :
faiss.write_index(index, "trained_block.index") 

#       
#      :
for bno in range(first_block, last_block+ 1):
    block_vectors = vectors_parts[bno]
    block_vectors_ids = vectors_parts_ids[bno]  # id ,  
    index = faiss.read_index("trained_block.index")
    index.add_with_ids(block_vectors, block_vectors_ids)
    faiss.write_index(index, "block_{}.index".format(bno))


Depois disso, combinamos todas as listas invertidas. Isso é possível, já que cada um dos blocos é, de fato, o mesmo índice treinado, apenas com vetores diferentes.



ivfs = []
for bno in range(first_block, last_block+ 1):
    index = faiss.read_index("block_{}.index".format(bno), faiss.IO_FLAG_MMAP)
    ivfs.append(index.invlists)
    #  index   inv_lists 
    #        :
    index.own_invlists = False

#   :
index = faiss.read_index("trained_block.index")

#   invlists
#  invlists       merged_index.ivfdata
invlists = faiss.OnDiskInvertedLists(index.nlist, index.code_size, "merged_index.ivfdata")
ivf_vector = faiss.InvertedListsPtrVector() 

for ivf in ivfs: 
    ivf_vector.push_back(ivf)

ntotal = invlists.merge_from(ivf_vector.data(), ivf_vector.size())
index.ntotal = ntotal  #     
index.replace_invlists(invlists)  
faiss.write_index(index, data_path + "populated.index")  #    


Conclusão: agora nosso índice é populated.index e arquivos merged_blocks.ivfdata .



Em populated.index registrou o caminho completo original para o arquivo com as Listas Invertidas, portanto, se o caminho do arquivo ivfdata, por algum motivo, a alteração na leitura do índice precisar usar a bandeira faiss.IO_FLAG_ONDISK_SAME_DIR , que permite procurar o arquivo ivfdata no mesmo diretório que o populated.index:



index = faiss.read_index('populated.index', faiss.IO_FLAG_ONDISK_SAME_DIR)


O exemplo de demonstração do projeto Github FAISS foi tomado como base .



Um mini-guia para escolher um índice pode ser encontrado no Wiki da FAISS . Por exemplo, conseguimos colocar um conjunto de dados de treinamento de 12 milhões de vetores na RAM, por isso escolhemos o índice IVFFlat em 262144 centróides e, em seguida, escalamos para centenas de milhões. Também é proposto o uso do índice IVF262144_HNSW32 no guia, no qual a afiliação de um vetor a um cluster é determinada pelo algoritmo HNSW com 32 vizinhos mais próximos (em outras palavras, usando o quantizador IndexHNSWFlat), mas, como nos parecia em outros testes, a pesquisa por esse índice é menos precisa. Além disso, deve-se ter em mente que esse quantizador elimina a possibilidade de uso na GPU.



Spoiler:
on disk inverted lists FAISS . RAM , , , RAM . FAISS wiki Github , .





Reduza drasticamente o uso do disco com a quantização do produto



Graças ao método de pesquisa de disco, foi possível remover a carga da RAM, mas o índice com um milhão de vetores ainda ocupava cerca de 2 GB de espaço em disco, e estamos discutindo a possibilidade de trabalhar com bilhões de vetores, o que exigiria mais de dois TB! Obviamente, o volume não é tão grande se você definir uma meta e alocar espaço em disco adicional, mas isso nos incomodou um pouco.



E aqui a codificação vetorial vem em socorro, a saber: Quantização Escalar (SQ) e Quantização de Produto (PQ)... SQ é a codificação de cada componente vetorial com n bits (geralmente 8, 6 ou 4 bits). Vamos considerar a opção PQ, porque a idéia de codificar um único componente do tipo float32 com oito bits parece muito deprimente em termos de precisão. Embora em alguns casos a compactação do SQfp16 para float16 seja quase sem perda de precisão.



A essência da quantização do produto é a seguinte: os vetores da dimensão 512 são divididos em n partes, cada uma das quais é agrupada em 256 possíveis agrupamentos (1 byte), ou seja, nós representamos um vetor com n bytes, onde n é geralmente no máximo 64 em uma implementação FAISS. Mas essa quantização é aplicada não aos próprios vetores do conjunto de dados, mas às diferenças desses vetores e dos centróides correspondentes obtidos no estágio de geração de Listas Invertidas! Acontece que as Listas Invertidas serão conjuntos codificados de distâncias entre vetores e seus centróides.



index = faiss.index_factory(dim, "IVF262144,PQ64", faiss.METRIC_L2)


Acontece que agora não é necessário armazenar todos os vetores - basta alocar n bytes por vetor e 2048 bytes por cada vetor de centróide. No nosso caso, pegamosn=64, ou seja 51264=8- o comprimento de um subvetor, definido em um dos 256 clusters.







Ao pesquisar pelo vetor x, os centróides mais próximos serão determinados primeiro com o quantizador plano usual e, em seguida, x também será dividido em subvectores, cada um dos quais é codificado pelo número de um dos 256 centróides correspondentes. E a distância para o vetor é definida como a soma de 64 distâncias entre os sub-vetores.



Qual é o resultado?



Interrompemos nossos experimentos no índice “IVF262144, PQ64”, uma vez que satisfazia completamente todas as nossas necessidades de velocidade e precisão da pesquisa, além de garantir o uso razoável do espaço em disco com redimensionamento adicional do índice. Mais especificamente, no momento, com 315 milhões de vetores, o índice ocupa 22 GB de espaço em disco e cerca de 3 GB de RAM em uso.



Outro detalhe interessante que não mencionamos anteriormente é a métrica usada pelo índice. Por padrão, as distâncias entre dois vetores são calculadas na métrica Euclidean L2 ou, em linguagem mais compreensível, as distâncias são calculadas como a raiz quadrada da soma dos quadrados das diferenças em coordenadas. Mas você pode definir outra métrica, em particular, testamos a métrica METRIC_INNER_PRODUCT, ou métrica das distâncias do cosseno entre vetores. É cosseno porque o cosseno do ângulo entre dois vetores no sistema de coordenadas euclidianas é expresso como a proporção do produto escalar (coordenado) dos vetores para o produto de seus comprimentos, e se todos os vetores em nosso espaço tiverem comprimento 1, o cosseno do ângulo será exatamente igual ao produto em coordenadas. Nesse caso, quanto mais próximos os vetores estiverem no espaço, mais próximo da unidade estará o produto escalar.



A métrica L2 tem uma transição matemática direta para a métrica do produto escalar. No entanto, ao comparar experimentalmente as duas métricas, a impressão era de que a métrica de produtos escalares nos ajuda a analisar os coeficientes de similaridade das imagens de maneira mais bem-sucedida. Além disso, os incorporamentos de nossas fotos foram obtidos usandoInsightFace , que implementa a arquitetura ArcFace usando distâncias de cosseno. Também existem outras métricas nos índices FAISS que você pode ler aqui .



algumas palavras sobre a GPU
FAISS GPU , , , . GPU L2. 



, , PQ GPU 56- , float32 float16, .



FAISS GPU , CPU , , GPU . , :



faiss.omp_set_num_threads(N)




Conclusão e exemplos interessantes



Então, de volta para onde tudo começou. E tudo começou, lembramos, com a motivação para resolver o problema de encontrar bots na rede do Instagram e, mais especificamente - procure postagens duplicadas com pessoas ou avatares em determinados conjuntos de usuários. No processo de redação do material, ficou claro que uma descrição detalhada de nossa metodologia para procurar bots exigiria um artigo separado, que discutiremos nas publicações a seguir, mas, por enquanto, nos limitaremos a exemplos de nossos experimentos com o FAISS.



Você pode vetorizar imagens ou rostos de maneiras diferentes. Escolhemos a tecnologia InsightFace (vetorizar imagens e extrair recursos n-dimensionais delas é uma longa história separada). No decorrer de experimentos com a infraestrutura que recebemos, foram descobertas propriedades bastante interessantes e engraçadas.



Por exemplo, tendo garantido a permissão de colegas e conhecidos, colocamos seus rostos na pesquisa e encontramos rapidamente as fotografias nas quais eles estão presentes:





Nosso colega tirou uma foto de um visitante da Comic-Con, estando no fundo no meio de uma multidão. Faça um





piquenique de origem em um grande grupo de amigos, foto da conta de um amigo. Fonte





Apenas de passagem. Um fotógrafo desconhecido capturou os caras por seu perfil temático. Eles não sabiam para onde foi a foto e depois de cinco anos se esqueceram completamente de como foram fotografados. Fonte





Nesse caso, o fotógrafo é desconhecido e fotografado secretamente!

Lembrou imediatamente a garota suspeita com SLR, sentada no momento em frente a :) Fonte




Assim, através de ações simples, o FAISS permite coletar no joelho um análogo do conhecido FindFace.



Outra característica interessante: no índice FAISS, quanto mais as faces são semelhantes, mais próximas são os vetores correspondentes no espaço. Eu decidi dar uma olhada nos resultados de pesquisa um pouco menos precisos no meu rosto e encontrei clones terrivelmente parecidos comigo :)





Alguns dos clones do autor.

Fontes fotográficas: 1 , 23




De um modo geral, a FAISS abre um campo enorme para a implementação de quaisquer idéias criativas. Por exemplo, usando o mesmo princípio de proximidade vetorial de faces semelhantes, é possível construir caminhos de uma pessoa para outra. Ou, como último recurso, faça do FAISS uma fábrica para a produção de tais memes:





Fonte



Obrigado por sua atenção e esperamos que este material seja útil para os leitores da Habr!



Este artigo foi escrito com o apoio de meus colegas Artyom Korolyov (korolevart), Timur Kadyrov e Arina Reshetnikova.



Rede de Pesquisa e Desenvolvimento Dentsu Aegis Russia.



All Articles