机器学习常用的KDTree 深度讲解Word下载.docx
- 文档编号:21026765
- 上传时间:2023-01-27
- 格式:DOCX
- 页数:12
- 大小:237.68KB
机器学习常用的KDTree 深度讲解Word下载.docx
《机器学习常用的KDTree 深度讲解Word下载.docx》由会员分享,可在线阅读,更多相关《机器学习常用的KDTree 深度讲解Word下载.docx(12页珍藏版)》请在冰豆网上搜索。
rchild):
self.value
=
value
self.lchild
lchild
self.rchild
rchild
build(arr):
n
len(arr):
left,
right
arr[:
n//2],
arr[n//2:
]
self.build(left),
self.build(right)
return
Node(max(lchild.value,
rchild.value),
rchild)
我们来看一个二维的例子,在一个二维的平面当中分布着若干个点。
我们首先选择一个维度将这些数据一分为二,比如我们选择x轴。
我们对所有数据按照x轴的值排序,选出其中的中点进行一分为二。
在这根线左右两侧的点被分成了两棵子树,对于这两个部分的数据来说,我们更换一个维度,也就是选择y轴进行划分。
一样,我们先排序,然后找到中间的点,再次一分为二。
我们可以得到:
我们重复上述过程,一直将点分到不能分为止,为了能更好地看清楚,我们对所有数据标上坐标(并不精确)。
如果我们把空间看成是广义的区间,那么它和线段树的原理是一样的。
最后得到的也是一棵完美二叉树,因为我们每次都选择了数据集的中点进行划分,可以保证从树根到叶子节点的长度不会超过
。
我们代入上面的坐标之后,我们最终得到的KD-Tree大概是下面这个样子:
KD-Tree建树
在建树的过程当中,我们的树深每往下延伸一层,我们就会换一个维度作为衡量标准。
原因也很简单,因为我们希望这棵树对于这K维空间都有很好的表达能力,方便我们根据不同的维度快速查询。
在一些实现当中,我们会计算每一个维度的方差,然后选择方差较大的维度进行切分。
这样做自然是因为方差较大的维度说明数据相对分散,切分之后可以把数据区分得更加明显。
但我个人觉得这样做意义不是很大,毕竟计算方差也是一笔开销。
所以这里我们选择了最朴素的方法——轮流选择。
也就是说我们从树根开始,选择第0维作为排序和切分数据的依据,然后到了树深为1的这一层,我们选择第一维,树深为2的这一层,我们选择第二维,以此类推。
当树深超过了K的时候,我们就对树深取模。
明确了这一点之后,我们就可以来写KD-Tree的建树代码了,和上面二叉树的代码非常相似,只不过多了维度的处理而已。
#
外部暴露接口
build_model(self,
dataset):
self.root
self._build_model(dataset)
先忽略,容后再讲
self.set_father(self.root,
None)
内部实现的接口
_build_model(self,
dataset,
depth=0):
if
len(dataset)
==
0:
None
通过树深对K取模来获得当前对哪一维切分
axis
depth
%
self.K
m
//
2
根据axis这一维排序
dataset
sorted(dataset,
key=lambda
x:
x[axis])
将数据一分为二
mid,
dataset[:
m],
dataset[m],
dataset[m+1:
递归建树
KDTree.Node(
mid[axis],
mid,
axis,
depth,
len(dataset),
self._build_model(left,
depth+1),
self._build_model(right,
depth+1)
)
这样我们就建好了树,但是在后序的查询当中我们需要访问节点的父节点,所以我们需要为每一个节点都赋值指向父亲节点的指针。
这个值我们可以写在建树的代码里,但是会稍稍复杂一些,所以我把它单独拆分了出来,作为一个独立的函数来给每一个节点赋值。
对于根节点来说,由于它没有父亲节点,所以赋值为None。
我们来看下set_father当中的内容,其实很简单,就是一个树的递归遍历:
set_father(self,
node,
father):
node
is
None:
return
赋值
node.father
father
递归左右
self.set_father(node.lchild,
node)
self.set_father(node.rchild,
快速批量查询
KD-Tree建树建好了肯定是要来用的,它最大的用处是可以在单次查询中获得距离样本最近的若干个样本。
在分散均匀的数据集当中,我们可以在的时间内完成查询,但是对于特殊情况可能会长一些,但是也比我们通过朴素的方法查询要快得多。
我们很容易发现,KD-Tree一个广泛的使用场景是用来优化KNN算法。
我们在之前介绍KNN算法的文章当中曾经提到过,KNN算法在预测的时候需要遍历整个数据集,然后计算数据集中每一个样本与当前样本的距离,选出最近的K个来,这需要大量的开销。
而使用KD-Tree,我们可以在一次查询当中直接查找到K个最近的样本,因此大大提升KNN算法的效率。
那么,这个查询操作又是怎么实现的呢?
这个查询基于递归实现,因此对于递归不熟悉的小伙伴,可能初看会比较困难,可以先阅读一下之前关于递归的文章。
首先我们先通过递归查找到KD-Tree上的叶子节点,也就是找到样本所在的子空间。
这个查找应该非常容易,本质上来说我们就是将当前样本不停地与分割线进行比较,看看是在分割线的左侧还是右侧。
和二叉搜索树的元素查找是一样的:
iter_down(self,
data):
如果是叶子节点,则返回
node.lchild
None
and
node.rchild
node
如果左节点为空,则递归右节点
self.iter_down(node.rchild,
data)
同理,递归左节点
都不为空则和分割线判断是左还是右
node.axis
next_node
data[axis]
<
node.boundray
else
node.rchild
self.iter_down(next_node,
我们找到了叶子节点,其实代表样本空间当中的一小块空间。
我们来实际走一下整个流程,假设我们要查找3个点。
首先,我们会创建一个候选集,用来存储答案。
当我们找到叶子节点之后,这个区域当中只有一个点,我们把它加入候选集。
在上图当中紫色的x代表我们查找的样本,我们查找到的叶子节点之后,在两种情况下我们会把当前点加入候选集。
第一种情况是候选集还有空余,也就是还没有满K个,这里的K是我们查询的数量,也就是3。
第二种情况是当前点到样本的距离小于候选集中最大的一个,那么我们需要更新候选集。
这个点被我们访问过之后,我们会打上标记,表示这个点已经访问过了。
这个时候我们需要判断,整棵树当中的搜索是否已经结束,如果当前节点已经是根节点了,说明我们的遍历结束了,那么返回候选集,否则说明还没有,我们需要继续搜索。
上图当中我们用绿色表示样本被放入了候选集当中,黄色表示已经访问过。
由于我们的搜索还没有结束,所以需要继续搜索。
继续搜索需要判断样本和当前分割线的距离来判断和分割线的另一侧有没有可能存在答案。
由于叶子节点没有另一侧,所以作罢,我们往上移动一个,跳转到它的父亲节点。
我们计算距离并且查看候选集,此时候选集未满,我们加入候选集,标记为已经访问过。
它虽然存在分割线,但是也没有另一侧的节点,所以也跳过。
我们再往上,遍历到它的父亲节点,我们执行同样的判断,发现此时候选集还有空余,于是将它继续加入答案:
但是当我们判断到分割线距离的时候,我们发现这一次样本到分割线的举例要比之前候选集当中的最大距离要小,所以分割线的另一侧很有可能存在答案:
这里的d1是样本到分割线的距离,d2是样本到候选集当中最远点的距离。
由于到分割线更近,所以分割线的另一侧很有可能也存在答案,这个时候我们需要搜索分割线另一侧的子树,一直搜索到叶子节点。
我们找到了叶子节点,计算距离,发现此时候选集已经满了,并且它的距离大于候选集当中任何一个答案,所以不能构成新的答案。
于是我们只是标记它已经访问过,并不会加入候选集。
同样,我们继续往上遍历,到它的父节点:
比较之后发现,data到它的距离小于候选集当中最大的那个,于是我们更新候选集,去掉距离大于它的答案。
然后我们重复上述的过程,直到根节点为止。
由于后面没有更近的点,所以候选集一直没有更新,最后上图当中的三个打了绿标的点就是答案。
我们把上面的流程整理一下,就得到了递归函数当中的逻辑,我们用Python写出来其实已经和代码差不多了:
query(node,
data,
answers,
K):
判断node是否已经访问过
node.visited:
标记访问
node.visited
True
计算data到node中点的距离
dis
cal_dis(data,
node.point)
如果小于答案中最大值则更新答案
max(answers):
answers.update(node.point)
计算data到分割线的距离
node.split)
如果小于最长距离,说明另一侧还可能有答案
获取当前节点的兄弟节点
brother
self.get_brother(node)
not
往下搜索到叶子节点,从叶子节点开始寻找
leaf
self.iter_down(brother,
self.query(leaf,
K)
如果已经到了根节点了,退出
root:
answers
递归父亲节点
self.query(node.father,
else:
最终写成的代码和上面这段并没有太多的差别,在得到距离之后和答案当中的最大距离进行比较的地方,我们使用了优先队列。
其他地方几乎都是一样的,我也贴上来给大家感受一下:
_query_nearest_k(self,
path,
topK,
我们用set记录访问过的路径,而不是直接在节点上标记
in
path:
path.add(node)
计算欧氏距离
KDTree.distance(node.value,
(len(topK)
K
or
topK[-1]['
distance'
]):
topK.append({'
node'
:
'
dis})
使用优先队列获取topK
topK
heapq.nsmallest(K,
x['
])
分割线都是直线,直接计算坐标差
abs(data[axis]
-
node.boundray)
len(topK)
]:
self.get_brother(node,
path)
self._query_nearest_k(next_node,
self.root:
topK
self._query_nearest_k(node.father,
这段逻辑大家应该都能看明白,但是有一个疑问是,我们为什么不在node里面加一个visited的字段,而是通过传入一个set来维护访问过的节点呢?
这个逻辑只看代码是很难想清楚的,必须要亲手实验才会理解。
如果在node当中加入一个字段当然也是可以的,如果这样做的话,在我们执行查找之后必须得手动再执行一次递归,将树上所有节点的node全部置为false,否则下一次查询的时候,会有一些节点已经被标记成了True,显然会影响结果。
查询之后将这些值手动还原会带来开销,所以才转换思路使用set来进行访问判断。
这里的iter_down函数和我们上面贴的查找叶子节点的函数是一样的,就是查找当前子树的叶子节点。
如果我没记错的话,这也是我们文章当中第一次出现在递归当中调用另一个递归的情况。
对于初学者而言,这在理解上可能会相对困难一些。
我个人建议可以亲自动手试一试在纸上画一个kd-tree进行手动模拟试一试,自然就能知道其中的运行逻辑了。
这也是一个思考和学习非常好用的方法。
优化
当我们理解了整个kd-tree的建树和查找的逻辑之后,我们来考虑一下优化。
这段代码看下来初步可以找到两个可以优化的地方,第一个地方是我们建树的时候。
我们每次递归的时候由于要将数据一分为二,我们是使用了排序的方法来实现的,而每次排序都是的复杂度,这其实是不低的。
其实仔细想想,我们没有必要排序,我们只需要选出根据某个轴排序前n/2个数。
也就是说这是一个选择问题,并不是排序问题,所以可以想到我们可以利用之前讲过的快速选择的方法来优化。
使用快速选择,我们可以在
的时间内完成数据的拆分。
另一个地方是我们在查询K个邻近点的时候,我们使用了优先队列维护的候选集当中的答案,方便我们对答案进行更新。
同样,优先队列获取topK也是
的复杂度。
这里也是可以优化的,比较好的思路是使用堆来代替。
可以
做到的插入和弹出,相比于heapq的nsmallest方法要效率更高。
- 配套讲稿:
如PPT文件的首页显示word图标,表示该PPT已包含配套word讲稿。双击word图标可打开word文档。
- 特殊限制:
部分文档作品中含有的国旗、国徽等图片,仅作为作品整体效果示例展示,禁止商用。设计者仅对作品中独创性部分享有著作权。
- 关 键 词:
- 机器学习常用的KDTree 深度讲解 机器 学习 常用 KDTree 深度 讲解