1 引言

各位朋友大家好,欢迎来到月来客栈,我是掌柜空字符。

前面的文章中掌柜虽然已经详细地介绍了KNN的基本思想与原理,但对于具体的实现细节掌柜并没有过多的介绍。在这篇文章中,掌柜将会详细的来介绍如何从零完成KNN模型的构建,包括kd树的构建、遍历、最近邻搜索和k近邻搜索等等。下面,掌柜就先来介绍如何构建完成kd树。

2 kd

在文章[1]中掌柜也介绍过,kd树(k-Dimensional Tree)是一种空间划分数据结构,用于组织k维空间中的点,其本质上也就等同于二叉搜索树。不同的是,在kd树中每个节点存储的并不是一个值,而是一个k维的样本点[2]。因此在kd树中,所有的节点也都满足类似二叉搜索树的特性,即左子树中所有的样本点均“小于”其父节点中的样本点,而右子树中所有的样本点均“大于”或“等于”其父节点中的样本点。

因此,首先我们需要定义kd树中的节点信息,以及kd树的构建与查询等。

2.1 kd树节点定义

由于在KNN的预测结果中需要根据训练样本给出每个预测样本的标签值,因此就需要知道每个训练样本的原始标签值,故需要在节点中保存每个样本索引。最终,kd树的节点信息定义如下:

在上述代码中,第2-6行定义了节点Node中保存的具体信息,包括样本点、左右子树以及在原始样本中的索引;第8-9行定义了__str__()方法,其作用是在使用print()函数时可以直接打印出节点的信息,而不必用node.data这样的形式来访问节点中的样本。

2.2 kd树构建

在完成kd树节点的定义之后,下一步就可以开始定义构建KD树的整个过程。首先,我们需要定义类的初始化函数:

在上述代码中,第3行定义了kd树的根节点;第4行定义了原始样本的维度;第5行用于将在样本点(points)的最后一列附加上每个样本点的索引值;第6行则是调用insert()方法递归完成kd树的构建;第8-9行定义了一个方法来判断当前kd是否为空。

接下来便是完成insert()方法的实现过程,代码如下:

在上述代码中,第2-3行用来判断当前传入样本是否为空,如果为空则结束当前递归;第4行用于将当前样本按照某个维度的大小顺序进行排序,其中样本点维度的比较顺序为从左到右依次轮询,order在每次进行递归时都会累加;第6-7行行用来获取并保存当前样本点排序后中间位置的样本并保存到一个新初始化的节点中;第9、11行则是分别取当前排序后样本的左边部分和右边部分,以此来分别作为当前节点的左右子树;第16-17行则是分别递归构建左右子树。

上述logging模块的详细用法可以参加文章训练模型时如何保存训练日志?[3]

2.3 kd构建示例

在实现kd树的构建代码后,便可以通过如下方式来进行使用:

在上述代码中,第4行便是根据传入的样本点来递归的构建kd树;第7行则是将构建完成的kd树以层次遍历的方式打印出来。

以上代码运行结束后便会有类似如下信息输出:

在上述输出结果中,第3-17行便是kd树在构建过程中所输出的信息,需要再次提醒的是样本点的最后一个维度为当前样本点在原始样本中的索引;第20-35行则是构建完成后kd树的层次遍历结果。

根据层次遍历以及构建输出结果,也可以还原得到如图1所示的kd树。

图 1. kd树构建结果图

2.4 kd树最近邻搜索

在文章K近邻算法中掌柜详细介绍了kd树最近邻搜索的原理,因此在这里就不再赘述直接按照之前给出的伪代码(如下所示)来进行实现即可。

在实现最近邻的搜索过程之前,需要先给出距离的定义。这里掌柜使用的是Minkowski距离,即:

Lp(x(i),x(j))=(k=1m|xk(i)xk(j)|p)1p; p1(1)

p=2时就是我们熟悉的欧式距离,代码实现如下:

进一步,根据上述kd树最近邻搜索伪代码的实现过程,其对应的代码实现如下所示:

在上述代码中,第2-4行定义了相关的全局记录变量;第8行则是用来声明这三个遍历不是局部变量而是上面定义的全局变量;第10行则是用来记录当前哪些节点已经访问过;第13行是计算当前节点到被搜索点的距离;第16-18行则是判断是否要更新当前最佳节点;第19行是计算得到进入左右子树时的判断维度;第20-23行是根据维度比较信息递归遍历相应的左子树或右子树;第24-27行则是根据子空间排除原理来判断当前节点左右子树中未访问过的节点是否存在最佳节点,并进行递归遍历。

最后,可以通过如下方式来进行kd树中最近邻样本点的搜索:

上述代码运行结束后便会输出类似如下的结果:

根据上述输出结果,可以得知距离样本点[5.9,3]最近的样本点是[7,3],并且整个KD树的搜索过程将如图2所示。

图 2. 最近邻搜索路径图

2.5 kd树K近邻搜索

同样,在文章[1]中掌柜已经详细介绍了kd树K近邻的搜索原理,因此在这里就不再赘述直接按照之前给出的伪代码(如下所示)来进行实现即可。

在实现K近邻搜索之前,我们先来定义一个辅助函数用于对节点进行插入并排序,代码如下:

在上述代码中,第2行是将当前节点加入到k_nearest_nodes中;第3-4行是根据k_nearest_nodes中每个样本点到被搜索点的距离来进行升序排序。

进一步,根据上述伪代码逻辑,便可以实现得到kd树的K近邻搜索逻辑,代码如下:

在上述代码中,第2-4行定义了相关的全局记录变量;第8行则是用来声明这三个遍历不是局部变量而是上面定义的全局变量;第10行用来记录访问过的节点;第11-13行是判断如果当前还没找到K个点,则直接进行保存;第14-18行是判断如果已经找到K个局部最优点,则开始进行筛选;第19-23行则是根据当前比较维度来判断进入左子树还是右子树进行递归遍历;第24-28行是根据子空间排除原理来判断当前节点左右子树中未访问过的节点是否存在最佳节点,并进行递归遍历。

到此,对于任一点的K近邻查找就已经实现完毕了,下面只需要再定义一个函数即可循环完成多个样本点的K近邻搜索过程,代码如下:

最后,可以通过如下方式来进行kd树中K近邻样本点的搜索:

上述代码运行结束后便会输出类似如下的结果:

到此,对于kd树的整体实现就介绍完了,接下来就是KNN分类模型的实现。

3 KNN

在完成kd的相关功能实现后,我们只需要在此基础上稍加封装便可以实现KNN分类模型。

3.1 模型拟合

对于KNN分类模型来说,所谓的模型拟合其实就是根据给定的训练样本来构造完成对应的kd树,实现代码如下所示:

在上述代码中,第2-3行为类KNN的初始化方法,用来保存K值;第5-8行则是模型的拟合过程,即建立kd树。

3.2 模型预测

在模型拟合完成后,便可以对新输入的样本进行预测。不过根据k_nearest_search()方法的返回结果可知,K近邻搜索返回的是训练集中K个节点对应的索引位置,因此需要先根据索引取到对应的标签值,然后再根据取到的标签通过投票法来确定新样本的类别。故,此处需要先定义一个辅助函数来完成类别的确定,实现代码如下:

在上述代码中,第1行query_label是一个二维数组,query_label[i] 表示离第i个样本最近的k个样本点对应的正确标签;第3-10行则是分别开始遍历每个样本的预测结果;第6-19行是根据投票规则来确定当前样本的所属类别。

进一步,我们便可以通过如下方式来完成新样本的预测:

最后,掌柜以iris数据集为例来进行实验,并同时与sklearn中KNeighborsClassifier的分类结果进行对比。

上述代码运行结束后的结果如下所示:

从上述结果可以看出,两者的分类准确率并没有任何差异。

4 总结

在这篇文章中,掌柜首先简单回顾了kd树的基本定义;然后分别详细介绍了kd树节点的定义、kd树的构建与示例、kd树的最近邻和K近邻搜索过程的全部实现;最后介绍了如何根据生成的kd树来实现KNN分类模型,包含模型的拟合与预测部分的编码实现,并同时以真实数据集iris进行了分类示例。

本次内容就到此结束,感谢您的阅读!如果你觉得上述内容对你有所帮助,欢迎点赞分享!若有任何疑问与建议,请添加掌柜微信nulls8(备注来源)或加群进行交流。青山不改,绿水长流,我们月来客栈见

引用

[1]第5章 K近邻算法(附高清PDF与教学PPT)

[2]https://en.wikipedia.org/wiki/K-d_tree

[3]训练模型时如何保存训练日志?