1. 介绍
Approximate Nearest Neighbor Oh Yeah,是一个带有Python bindings的C ++库。
2. 原理
随机选择两点进行超平面划分,在划分的子空间内不停递归划分,直至每个子空间最多只剩下k个数据结束。(必须通过精度和性能之间的权衡来调整k)
4. 问题和解决
问题:
解决:
6. 源码重点解释
https://blog.csdn.net/hero_fantao/article/details/70245387
Python代码示例:
from annoy import AnnoyIndex
import random
f = 20
t = AnnoyIndex(f, 'angular') # Length of item vector that will be indexed
for i in range(1000):
# 返回具有高斯分布的随机浮点数 random. gauss (mu, sigma) 参数:. mu:平均. sigma:标准偏差.
v = [random.gauss(0, 1) for z in range(f)]
t.add_item(i, v)
t.build(10) # 10 trees
t.save('test.ann')
print(t.get_nns_by_item(0, 10))
# [0,45,16,17,61,24,48,20,29,84]
# ...
u = AnnoyIndex(f, 'angular')
u.load('test.ann') # super fast, will just mmap the file
print(u.get_nns_by_item(0, 10)) # will find the 1000 nearest neighbors
# [0,45,16,17,61,24,48,20,29,84]
7. 完整的Python API
Notes:
Annoy使用归一化向量的欧式距离作为其角距离,对于两个向量u,v,其等于 sqrt(2(1-cos(u,v)))
C ++ API非常相似:调用annoy只需使用#include “annoylib.h”。
参考:
https://www.cnblogs.com/dangui/p/14675121.html#2-nsw%E7%AE%97%E6%B3%95%E5%8E%9F%E7%90%86
近邻图(Proximity Graph): 最朴素的图算法![在这里插入图片描述](https://img-blog.csdnimg.cn/33d30d02f81c4001b5af2a03491eb046.png?x-oss-process=image/watermark,type_ZHJvaWRzYW5zZmFsbGJhY2s,shadow_50,text_Q1NETiBAd2hhdHk2,size_20,color_FFFFFF,t_70,g_se,x_16)
思路(Target(红点)是待查询的向量):
构建图:每一个顶点连接着最近N个顶点。
搜索: 选择任一个顶点出发,首先遍历它的友节点,找到距离与Target最近的某一节点;
将此节点设为起始节点,再从它的友节点进行遍历;
反复迭代,不断逼近;
最后找到与Target距离最近的节点时搜索结束。
存在的问题:
1)孤立节点无法跟踪友节点(图中的K点)
2)若找TopN个,但点之间无连线,将影响查找效率(图中J\E\L点,由于L和J无连线,通过J找L需要多走一步)
3)友节点过多,增加了构造复杂度(D点)
4)若初始点选择较远,将进行多步查找
NSW (Navigable Small World graphs): 没有分层的可导航小世界的结构图
针对近邻图问题的解决:
1)孤立节点 -> 规定构图时所有节点必须有友节点
2)相似点不相邻 -> 距离相近到一定程度的节点必须互为友节点
3)友节点过多 -> 限制每个节点的友节点数量
4)初始点过远 -> 增加高速公路机制 (HNSW的最大优化点)
构建图(规定最多m个节点):
1)加入一个新节点,随机出发查找距离新节点最近的m个点,成为友节点;
2)更新 新节点 友节点的友节点,保证友节点个数最多是m。
构图实例:
第n次构造:在这个图的基础上再插入6个点,这6个点有3个和E很近,有3个和A很近,那么距离E最近的3个点中没有A,
距离A最近的3个点中也没有E,但因为A和E是构图早期添加的点,A和E有了连线,我们管这种连线叫“高速公路”,在查找
时可以提高查找效率(当进入点为E,待查找距离A很近时,我们可以通过AE连线从E直接到达A,而不是一小步一小步分
多次跳转到A)。
结论:
一个点,越早插入就越容易形成与之相关的“高速公路”连接,越晚插入就越难形成与之相关的“高速公路”连接。
HSW设计的妙处就在于扔掉德劳内(Delaunay)三角构图法,改用“无脑添加”(NSW朴素插入算法),降低了构图算法时间复杂度的同时还带来了数量有限的“高速公路”,加速了查找。
Delaunay 三角构图解释:
> https://zhuanlan.zhihu.com/p/264832755
算法:
设立三个点集合:Candidates(候选节点列表)、visitedSet(废弃节点列表)、result(保留topk个节点列表)![在这里插入图片描述](https://img-blog.csdnimg.cn/5a290b3c224a4ef48f5af336b9354f8a.png?x-oss-process=image/watermark,type_ZHJvaWRzYW5zZmFsbGJhY2s,shadow_50,text_Q1NETiBAd2hhdHk2,size_20,color_FFFFFF,t_70,g_se,x_16)
伪代码:
K-NNSearch(object q, integer: m, k)
TreeSet [object] candidates, visitedSet, result
/*
输入:
q: 新查询点
m: number of multi-searches, 多次搜索的数量
k: number of nearest neighbors, 最近邻的数量
*/
// 进行m次循环,避免随机性
for (i←0; i < m; i++) do:
put random entry point in candidates
repeat:
// 从candidates中找到距离q最近的点c
get element c closest from candidates to q
remove c from candidates
// 判断结束条件
if c is further than k-th element from result then
break repeat
// 更新后选择列表
for every element e from friends of c do:
if e is not in visitedSet then
add e to visitedSet
if distance e to q is smaller f to q:
add e to candidates、result
end repeat
end for
return best k elements from result
详解:
https://blog.csdn.net/weixin_41462047/article/details/81253106
HNSW(Hierachral Navigable Small World graphs):NSW的改进,具有分层的可导航小世界的结构图;根据连接的长度(距离)将连接划分为不同的层,然后在**多层图中进行搜索**。
![在这里插入图片描述](https://img-blog.csdnimg.cn/eec91c7a7ff54e628ac4cf0f8364a6aa.png?x-oss-process=image/watermark,type_ZHJvaWRzYW5zZmFsbGJhY2s,shadow_50,text_Q1NETiBAd2hhdHk2,size_20,color_FFFFFF,t_70,g_se,x_16)
建图:
插入新点时,先计算这个点可以深入到第几层,在每层的NSW图中查找t个最近邻点,分别连接它们,对每层图都进行如此操作。
对于每个插入的元素,将以指数衰减概率分布(通过mL参数归一化)随机选择一个最大层 L= ⌊−ln(uniform(0,1)) ⋅ mL⌋
查找:
1)从顶层任意点开始查找,选择一个进入点enter point,将进入点最邻近的一些友节点储在定长的动态列表result中,并把它们也同样在废弃列表visitedSet中存一份,以防后面走冤枉路。
2)一般地,在第x次查找时,先计算动态列表result中所有点的友节点距离待查找点q的距离,在废弃列表visitedSet中记录过的友节点不要计算,计算完后更新废弃列表visitedSet,不走冤枉路,再把这些计算完的友节点存入动态列表result,去重排序,保留前k个点,看看这k个点和更新前的k个点是不是一样的,如果不是一样的,继续查找,如果是一样的,返回前m个结果。
算法:
INSERT(hnsw,q,M,Mmax,efConstruction,mL) :新元素q插入算法。
INSERT(hnsw, q, M, Mmax, efConstruction, mL)
/**
* 输入
* hnsw:q插入的目标图
* q:插入的新元素
* M:每个点需要与图中其他的点建立的连接数
* Mmax:最大的连接数,超过则需要进行缩减(shrink)
* efConstruction:动态候选元素集合大小
* mL:选择q的层数时用到的标准化因子
*/
Input:
multilayer graph hnsw,
new element q,
number of established connections M,
maximum number of connections for each element per layer Mmax,
size of the dynamic candidate list efConstruction,
normalization factor for level generation mL
/**
* 输出:新的hnsw图
*/
Output: update hnsw inserting element q
W ← ∅ // W:现在发现的最近邻元素集合
ep ← get enter point for hnsw
L ← level of ep
/**
* unif(0..1)是取0到1之中的随机数
* 根据mL获取新元素q的层数l
*/
l ← ⌊-ln(unif(0..1))∙mL⌋
/**
* 自顶层向q的层数l逼近搜索,一直到l+1,每层寻找当前层q最近邻的1个点
* 找到所有层中最近的一个点作为q插入到l层的入口点
*/
for lc ← L … l+1
W ← SEARCH_LAYER(q, ep, ef=1, lc)
ep ← get the nearest element from W to q
// 自l层向底层逼近搜索,每层寻找当前层q最近邻的efConstruction个点赋值到集合W
for lc ← min(L, l) … 0
W ← SEARCH_LAYER(q, ep, efConstruction, lc)
// 在W中选择q最近邻的M个点作为neighbors双向连接起来
neighbors ← SELECT_NEIGHBORS(q, W, M, lc)
add bidirectional connectionts from neighbors to q at layer lc
// 检查每个neighbors的连接数,如果大于Mmax,则需要缩减连接到最近邻的Mmax个
for each e ∈ neighbors
eConn ← neighbourhood(e) at layer lc
if │eConn│ > Mmax
eNewConn ← SELECT_NEIGHBORS(e, eConn, Mmax, lc)
set neighbourhood(e) at layer lc to eNewConn
ep ← W
if l > L
set enter point for hnsw to q
**SEARCH_LAYER(q,ep,ef,lc)** :在第lc层查找距离q最近邻的ef个元素。
SEARCH_LAYER(q, ep, ef, lc)
/**
* 输入
* q:插入的新元素
* ep:进入点 enter point
* ef:需要返回的近邻数量
* lc:层数
*/
Input:
query element q,
enter point ep,
number of nearest to q elements to return ef,
layer number lc
/**
* 输出:q的ef个最近邻
*/
Output: ef closest neighbors to q
v ← ep // v:设置访问过的元素 visited elements
C ← ep // C:设置候选元素 candidates
W ← ep // W:现在发现的最近邻元素集合
// 遍历每一个候选元素,包括遍历过程中不断加入的元素
while │C│ > 0
// 取出C中q的最近邻c
c ← extract nearest element from C to q
// 取出W中q的最远点f
f ← get furthest element from W to q
if distance(c, q) > distance(f, q)
break
/**
* 当c比f距离q更近时,则将c的每一个邻居e都进行遍历
* 如果e比w中距离q最远的f要更接近q,那就把e加入到W和候选元素C中
* 由此会不断地遍历图,直至达到局部最佳状态,c的所有邻居没有距离更近的了或者所有邻居都已经被遍历了
*/
for each e ∈ neighbourhood(c) at layer lc
if e ∉ v
v ← v ⋃ e
f ← get furthest element from W to q
if distance(e, q) < distance(f, q) or │W│ < ef
C ← C ⋃ e
W ← W ⋃ e
// 保证返回的数目不大于ef
if │W│ > ef
remove furthest element from W to q
return W
在 HNSW 中,SEARCH-LAYER(q, ep, ef, lc) 返回 efConstruction 个最近邻点,我们知道 efConstruction 的值是大于 M 的,那么怎么在这些点中选择 M 个来进行双向连接呢?这时候就有一个选择算法了。论文中提出了两种选择算法:
- 简单选择算法 SELECT-NEIGHBORS-SIMPLE(q, C, M),到最接近的elements的简单连接。
- 启发式选择算法 SELECT-NEIGHBORS-HEURISTIC(q, C, M, lc, extendCandidates, keepPrunedConnections),会考虑上candidate elements间距离,用来创建不同方向(diverse directions)的连接。
选择算法(简单选择或是启发式选择)的作用就是:**在集合 W 中选择 M(M
**SELECT_NEIGHBORS_HEURISTIC(q,C,M,lc,extendCandidates,keepPrunedConnections)** :启发式寻找最近邻。
启发式搜索:
启发式选择:**当目标点到插入点的距离 比 目标点到插入点的友节点 近,就把目标点和插入点连接起来**。
两个额外参数:
extendCandidates:(缺省为false),它会扩展candidate set,只对极度聚集的数据有用
keepPrunedConnections:允许每个element具有固定数目的connection,当被插入的elements的connections在zero layer被确立时,插入过程终止。
SELECT_NEIGHBORS_HEURISTIC(q, C, M, lc, extendCandidates, keepPrunedConnections)
/**
* 输入
* q:查询的点
* C:候选元素集合
* M:需要返回的数目
* lc:层数
* extendCandidates:指示是否扩展候选列表的标志
* keepPrunedConnections:指示是否添加丢弃元素的标志
*/
Input:
base element q,
candidate elements C,
number of neighbors to return M,
layer number lc,
flag indicating whether or not to extend candidate list extendCandidates,
flag indicating whether or not to add discarded elements keepPrunedConnections
/**
* 输出:探索得到M个元素
*/
Output: M elements selected by the heuristic
R ← ∅ // 记录结果
W ← C // W:候选元素的队列
if extendCandidates // 通过邻居来扩充候选元素
for each e ∈ C
for each e_adj ∈ neighbourhood(e) at layer lc
if e_adj ∉ W
W ← W ⋃ e_adj
Wd ← ∅ // 丢弃的候选元素的队列
/**
* 这里是关键,他的意思就是:
* 候选元素队列不为空且结果数量少于M时,在W中选择q最近邻e
* 如果e和q的距离比e和R中的其中一个元素的距离更小,就把e加入到R中,否则就把e加入Wd(丢弃)
* 可以理解成:如果R中存在点r,使distance(q,e) < distance(q,r),则加入点e到R
*/
while │W│ > 0 and │R│ < M
e ← extract nearest element from W to q
if e is closer to q compared to any element from R
R ← R ⋃ e
else
Wd ← Wd ⋃ e
/**
* 如果设置keepPrunedConnections为true,且R不满足M个,那就在丢弃队列中挑选最近邻填满R为M个
*/
if keepPrunedConnections
while │Wd│ > 0 and │R│ < M
R ← R ⋃ extract nearest element from Wd to q
return R
5)KNN查询
K−NN−SEARCH(hnsw,q,K,ef) :在 hnsw 索引中查询距离 q 最近邻的 K 个元素。
K-NN-SEARCH(hnsw, q, K, ef)
/**
* 输入
* hnsw:q插入的目标图
* q:查询元素
* K:返回的近邻数量
* ef:动态候选元素集合大小
*/
Input:
multilayer graph hnsw, query element q,
number of nearest neighbors to return K,
size of the dynamic candidate list ef
/**
* 输出:q的K个最近邻元素
*/
Output: K nearest elements to q
W ← ∅ // W:现在发现的最近邻元素集合
ep ← get enter point for hnsw
L ← level of ep
/**
* 自顶层向倒数第2层逼近搜索,每层寻找当前层q最近邻的1个点赋值到集合W
* 取W中最接近q的点作为底层的入口点,以便使搜索的时间成本最低
*/
for lc ← L … 1
W ← SEARCH_LAYER(q, ep, ef=1, lc)
ep ← get nearest element from W to q
// 从上一层得到的ep点开始搜索底层获得ef个q的最近邻
W ← SEARCH_LAYER(q, ep, ef, lc=0)
return K nearest elements from W to q
算法复杂度分析:
实现HNSW主要有两个package可选用:
源码:
https://github.com/facebookresearch/faiss
介绍:
https://engineering.fb.com/2017/03/29/data-infrastructure/faiss-a-library-for-efficient-similarity-search/
HNSE demos:
https://github.com/facebookresearch/faiss/blob/13a2d4ef8fcb4aa8b92718ef4b9cc211033e7318/benchs/bench_hnsw.py
demo:
"Build Index"
# Dim: Embedding demension
# M, ef_construction: defined in the paper "Efficient and robust approximate
# nearest neighbor search using Hierarchical Navigable Small World graphs"
index = faiss.IndexHNSWFlat(dim, M)
index.hnsw.efConstruction = ef_construction
index.verbose = True # to see progress
index.add(vecs) # vecs: a n2-by-d matrix with query vectors
"Save index to file and load index from file"
# save index
faiss.write_index(index, file)
# load index
index = faiss.read_index(file)
"Search in the index"
# vecs: a n2-by-d matrix with query vectors
# D: distance
# I: Indexes of returned candidates
# k: number of nearest candidates
D, I = index.search(vecs, k)
"Evaluate the index"
nq, d = vecs.shape
t0 = time.time()
D, I = index.search(vecs, k)
t1 = time.time()
missing_rate = (I == -1).sum() / float(nq*k)
recall_at_1 = (I == np.arange(nq)).sum() / float(nq*k)
print("\t %7.3f ms per query, R@1 %.4f, missing rate %.4f" % (
(t1 - t0) * 1000.0 / nq, recall_at_1, missing_rate))
https://github.com/nmslib/hnswlib
demo:
import hnswlib
import numpy as np
dim = 16
num_elements = 10000
# Generating sample data
data = np.float32(np.random.random((num_elements, dim)))
# We split the data in two batches:
data1 = data[:num_elements // 2]
data2 = data[num_elements // 2:]
# Declaring index
p = hnswlib.Index(space='l2', dim=dim) # possible options are l2, cosine or ip
# Initializing index
# max_elements - the maximum number of elements (capacity). Will throw an exception if exceeded
# during insertion of an element.
# The capacity can be increased by saving/loading the index, see below.
#
# ef_construction - controls index search speed/build speed tradeoff
#
# M - is tightly connected with internal dimensionality of the data. Strongly affects memory consumption (~M)
# Higher M leads to higher accuracy/run_time at fixed ef/efConstruction
p.init_index(max_elements=num_elements//2, ef_construction=100, M=16)
# Controlling the recall by setting ef:
# higher ef leads to better accuracy, but slower search
p.set_ef(10)
# Set number of threads used during batch search/construction
# By default using all available cores
p.set_num_threads(4)
print("Adding first batch of %d elements" % (len(data1)))
p.add_items(data1)
# Query the elements for themselves and measure recall:
labels, distances = p.knn_query(data1, k=1)
print("Recall for the first batch:", np.mean(labels.reshape(-1) == np.arange(len(data1))), "\n")
# Serializing and deleting the index:
index_path='first_half.bin'
print("Saving index to '%s'" % index_path)
p.save_index("first_half.bin")
del p
# Re-initializing, loading the index
p = hnswlib.Index(space='l2', dim=dim) # the space can be changed - keeps the data, alters the distance function.
print("\nLoading index from 'first_half.bin'\n")
# Increase the total capacity (max_elements), so that it will handle the new data
p.load_index("first_half.bin", max_elements = num_elements)
print("Adding the second batch of %d elements" % (len(data2)))
p.add_items(data2)
# Query the elements for themselves and measure recall:
labels, distances = p.knn_query(data, k=1)
print("Recall for two batches:", np.mean(labels.reshape(-1) == np.arange(len(data))), "\n")
三、KD Tree
Kd-tress(K dimensional Tree):平衡二叉树(AVL树)
算法:
K-D Tree建立:
不断分裂空间;
分裂点:计算每个点的坐标的每一个维度上的方差,取方差最大的那一维对应的中间值。
直到每个空间中最多有一个点。
Input: 无序化的点云,维度k
Output:点云对应的kd-tree
Algorithm:
1、初始化分割轴:对每个维度的数据进行方差的计算, **取最大方差的维度作为分割轴**,标记为r;
2、确定节点:对当前数据按分割轴维度进行检索,找到**中位数数据,并将其放入到当前节点上**;
3、划分双支:
划分左支:在当前分割轴维度,所有**小于中位数的值划分到左支**中;
划分右支:在当前分割轴维度,所有**大于等于中位数的值划分到右支**中。
4、更新分割轴:r = (r + 1) % k;
5、确定子节点:
确定左节点:在左支的数据中进行步骤2;
确定右节点:在右支的数据中进行步骤2;
例子:
二维样例:{(2,3),(5,4),(9,6),(4,7),(8,1),(7,2)}
构建步骤:
1、确定 方差大 的为开始分割轴:
发现x轴的方差较大,所以,最开始的分割轴为x轴。
2、该轴的 中位数 确定为当前节点:
对{2,5,9,4,8,7}找中位数,发现{5,7}都可以,这里我们选择7,也就是(7,2);
3、确定左右子树节点:
在x轴维度上,比较和7的大小,进行划分:
左支:{(2,3),(5,4),(4,7)}
右支:{(9,6),(8,1)}
4、更新 另一个分割轴 继续划分:
一共就两个维度,所以,下一个维度是y轴。
5、换新轴 确定左右子树 子节点:
左节点:在左支中找到y轴的中位数(5,4),左支数据更新为{(2,3)},右支数据更新为{(4,7)}
右节点:在右支中找到y轴的中位数(9,6),左支数据更新为{(8,1)},右支数据为null。
6、更新分割轴:
下一个维度为x轴。
7、确定(5,4)的子节点:
左节点:由于只有一个数据,所以,左节点为(2,3)
右节点:由于只有一个数据,所以,右节点为(4,7)
8、确定(9,6)的子节点:
左节点:由于只有一个数据,所以,左节点为(8,1)
右节点:右节点为空。
最终,就可以构建整个的kd-tree了。
搜索一个最近邻:定位到对应的分支上,找到最接近的点。
举个例子:查找(2.1,3.1)的最近邻。
计算当前节点(7,2)的距离,为6.23,并且暂定为(7,2),根据当前分割轴的维度(2.1 < 7),选取左支。
计算当前节点(5,4)的距离,为3.03,由于3.03 < 6.23,暂定为(5,4),根据当前分割轴维度(3.1 < 4),选取左支。
计算当前节点(2,3)的距离,为0.14,由于0.14 < 3.03,暂定为(2,3),根据当前分割轴维度(2.1 > 2),选取右支,而右支为空,回溯上一个节点。
计算(2.1,3.1)与(5,4)的分割轴{y = 4}的距离,如果0.14小于距离值,说明就是最近值。如果大于距离值,说明,还有可能存在值与(2.1,3.1)最近,需要往右支检索。
由于0.14 < 0.9,我们找到了最近邻的值为(2,3),最近距离为0.14。
多个最近邻:多个近邻其实和一个最近邻类似,不过是存储区间变为了多个,判定方法还是完全一样。
详细介绍:
https://www.joinquant.com/view/community/detail/c2c41c79657cebf8cd871b44ce4f5d97