K近邻算法(k-nearest neighbor,KNN)

1,194 阅读16分钟

K近邻算法(k-nearest neighbor,KNN)

经过一周昏天黑地的加班之后,终于到了周末,又感觉到生活如此美好,遂提笔写一写KNN,这个也许是机器学习众多算法中思想最为简单直白的算法了,其分类思想,总结起来就一句话:近朱者赤,近墨者黑。当然,KNN也可以用于回归任务,在回归任务中,采用“平均法”,即把离预测样本最近的K个样本的label(连续值)取平均作为预测结果,也可以根据距离远近进行加权平均。我们这篇博客主要关注分类任务,因此回归任务不再多提。我主要想从以下几个方面介绍K近邻算法(KNN):

  • KNN分类的基本思想
  • KNN中K值大小对预测结果的影响
  • KNN中距离度量方式
  • kd树的构造与搜索

Note: 个人认为对于初学者,KNN中最难也是最重要的知识点就是kd树了。

一、KNN分类的基本思想

首先,KNN是一种监督学习方法, 其原理为:给定测试样本,基于某种距离度量找出训练集中与其最近的k个样本,然后基于这个k个样本(邻居)的label信息来进行预测。在分类任务中,通常采用“投票法”,少数服从多数原则,即选择这k个样本中出现最多的类别作为预测结果。当然也可以按照距离远近进行加权“投票”,距离近的样本权重大,“投票权”大。下面给出KNN分类器的示意图,来直观感受下,KNN是如何分类的。

上图给出了1-近邻,2-近邻,3-近邻的示意图, x 表示待预测样本。在1-近邻中,由于距离待预测样本最近的样本类别为负,因此预测待预测样本为负。3-近邻中,距离最近的3个样本中,两个为正,投票表决,少数服从多数,则把待预测样本标记为正。2-近邻中,距离最近的两个样本一正一负,此时可以随机选取类别。
       通过上面的例子也能够看出,KNN并不需要事先训练一个模型,而是直到需要分类测试样本时才进行。这种学习方法叫做 懒惰学习(lazy learning),这类技术在训练阶段仅仅把样本保存起来,训练时间开销为0,待收到测试样本时再处理。而且我们讲过的神经网络,决策树,logistic 回归,SVM等在训练阶段就学习模型的方法,称为急切学习(eager learning)。下面稍微形式化一点用伪代码来描述下KNN分类算法的流程:

上面 I(.)为指示函数,当yi=cj yi=cjyi​=cj​时I为1,否则为0。

这里加一句题外话,KNN虽然思想简单,但是性能是很不错的,能够理论证明KNN的泛化错误率不超过贝叶斯最优分类器的错误率的两倍。(相关理论证明,参见周志华《机器学习》p226)。从以上的基本原理中,我们大概能够得到KNN算法的三个关键点:

  1. K值的大小对预测结果的影响
  2. 距离度量方式
  3. 如何找到与测试样本距离最近的K个样本

下面会分别介绍这三方面的内容。

二、KNN中K值大小对预测结果的影响

       从上面原理中,能够知道KNN中K值是需要自己指定的,关于K值大小对预测结果的影响如下:

  • 若K值太小,相当于用较小的邻域中的训练样本去预测,则KNN分类器容易受到由于训练数据中噪声的影响而产生过拟合。
  • 若K值太大,因为邻域比较大,则与测试样本距离较远的训练样本也会起作用,这样会导致误分类测试样本。
  • 若K=N K=NK=N,那么无论输入的测试样本是什么,输出都会是训练样本中样本数量最多的类,这种模型基本没什么用。

在实际应用中,k值一般选取一个比较小的值,可以通过交叉验证法来选取最优的K值。

三、KNN中距离度量方式

       既然要算距离,也就那么几个距离度量方式,大家最熟悉的就是欧氏距离了,KNN中一般常用欧式距离(李航《统计学习方法》),weka和scikit-learn中均默认为欧式距离。空间中两个空间点的Minkowski distance可定义为:
Lp(xi,xj)=(∑nl=1∣x(l)i−x(l)j∣)1p Lp(xi,xj)=(∑nl=1∣x(l)i−x(l)j∣)1pLp​(xi​,xj​)=(l=1∑n​∣xi(l)​−xj(l)​∣)p1​
这里,p≥1 p≥1p≥1。

  • 当 p=1 p=1p=1时,即,Lp(xi,xj)=(∑nl=1∣x(l)i−x(l)j∣) Lp(xi,xj)=(∑nl=1∣x(l)i−x(l)j∣)Lp​(xi​,xj​)=(∑l=1n​∣xi(l)​−xj(l)​∣),称为曼哈顿距离。
  • 当 p=2 p=2p=2时,即,Lp(xi,xj)=(∑nl=1∣x(l)i−x(l)j∣)12 Lp(xi,xj)=(∑nl=1∣x(l)i−x(l)j∣)12Lp​(xi​,xj​)=(∑l=1n​∣xi(l)​−xj(l)​∣)21​,也就是我们熟悉的欧氏距离。

当然还有其他距离度量的方式,在KNN里,这几个是比较常用的,尤其欧式距离。

四、kd树的构造与搜索

       终于要降到KNN中的核心问题了,对,就是 kd树的构造与搜索。先来说说KNN中为什么需要kd树?在KNN中要想找到与测试样本最近的K个样本,传统方法肯定是遍历一遍训练集中所有样本,这个复杂是O(N) O(N)O(N)的,K近邻的话还需要O(K∗N) O(K∗N)O(K∗N),当然,K取值一般比较小,因此复杂度是O(N) O(N)O(N),对于中小规模的数据集,这个复杂度基本没啥问题(weka中KNN默认搜索方式即为线性搜索),但是对于大规模数据而言,速度虽也能接受,但是略显有点慢。因此就需要kd树来解决这个问题。
       kd树是一种二叉树,实际上是一种存储结构,是一种对k维空间中的空间点进行存储以便对其进行快速检索的树形数据结构。我们已经知道了kd树就是一个二叉树,因此至少知道它长什么样子了,下面就是如何构造这课二叉树?构造kd树的步骤为(来自 李航《统计学习方法》):

上面的步骤总结下来其实就是每一层用样本特征的一个维度作为坐标轴,然后把所有样本按照此维度排序,选择中间的样本作为结点,然后前面的样本(在该维度上小于中间样本)进入左子树,大于的进入右子树。然后,递归。关于坐标轴的选择,比如第一层选取x(1) x(1)x(1)维度,第二层选取x(2) x(2)x(2)维度,第三层选择x(3) x(3)x(3)维度,
为了更加清晰的展现上面的步骤,我们再用两个例子来讲述(例子来自wiki和李航《统计学习方法》),假定我们有个特征为两维的数据集为:
T={(2,3)T,(5,4)T,(9,6)T,(4,7)T,(8,1)T,(7,2)T} T={(2,3)T,(5,4)T,(9,6)T,(4,7)T,(8,1)T,(7,2)T}T={(2,3)T,(5,4)T,(9,6)T,(4,7)T,(8,1)T,(7,2)T}
第一步:按照 x(1) x(1)x(1) 轴排序,排序结果:[(2,3),(4,7),(5,4),(7,2),(8,1),(9,6)] [(2,3),(4,7),(5,4),(7,2),(8,1),(9,6)][(2,3),(4,7),(5,4),(7,2),(8,1),(9,6)],选择中位数(中间样本),因此选择 (7,2) (7,2)(7,2)这个样本作为根节点,这样样本[(2,3),(4,7),(5,4)] [(2,3),(4,7),(5,4)][(2,3),(4,7),(5,4)]被划分为左子区域,[(8,1),(9,6)] [(8,1),(9,6)][(8,1),(9,6)]被划分为右子区域,如图所示:

第二步:对左子区域按照 x(2) x(2)x(2) 轴排序,排序结果为:[(2,3),(5,4),(4,7)] [(2,3),(5,4),(4,7)][(2,3),(5,4),(4,7)],因此选择中间样本(5,4) (5,4)(5,4) 作为划分点,[(2,3) [(2,3)[(2,3)被分到左子区域,[(4,7)] [(4,7)][(4,7)]被分到右子区域,如图所示:

第三步:对结点(5,4) (5,4)(5,4)的左子区域,按照 x(1) x(1)x(1) 轴排序,排序结果:[(2,3)] [(2,3)][(2,3)],因此选择样本[(2,3)] [(2,3)][(2,3)]作为划分点,如图所示:

第四步:结点(2,3) (2,3)(2,3)的左孩子为空,递归返回,执行右孩子,右孩子也为空,继续返回到结点(5,4) (5,4)(5,4)的右孩子。

下面的步骤就不写了,就是个递归的过程,最终构造出来的一棵树如图所示:

这个构造的过程也是很简单,直接可以写个代码更为方便:

from operator import itemgetter


def kd_tree(instances, depth=0):
    if not instances:
        return None

    k = len(instances[0])
    axis = depth % k
    print("============================")
    print("轴:%d" % (axis + 1))
    instances.sort(key=itemgetter(axis))

    print(instances)
    median = len(instances) // 2
    print(instances[median])
    kd_tree(instances[:median], depth + 1)
    kd_tree(instances[median + 1:], depth + 1)


if __name__ == '__main__ ':
    # example
    data = [(2, 3), (5, 4), (9, 6), (4, 7), (8, 1), (7, 2)]
    # data = [(2,3,1), (5,4,6), (9,6,7), (4,7,2), (8,1,3), (7,2,9)]
    kd_tree(data)

输出结果:

# output result
============================
轴:1
[(2, 3), (4, 7), (5, 4), (7, 2), (8, 1), (9, 6)]
(7, 2)
============================
轴:2
[(2, 3), (5, 4), (4, 7)]
(5, 4)
============================
轴:1
[(2, 3)]
(2, 3)
============================
轴:1
[(4, 7)]
(4, 7)
============================
轴:2
[(8, 1), (9, 6)]
(9, 6)
============================
轴:1
[(8, 1)]
(8, 1)

同样,也可以画出与这棵树等价的空间划分图,由于特征就2维,因此可以画个平面图出来:

再举个样本特征为3维的例子,样本集为:[(2,3,1),(3,3,2),(5,4,6),(9,6,7),(4,7,2),(4,8,5),(8,1,3),(7,2,9)] [(2,3,1),(3,3,2),(5,4,6),(9,6,7),(4,7,2),(4,8,5),(8,1,3),(7,2,9)][(2,3,1),(3,3,2),(5,4,6),(9,6,7),(4,7,2),(4,8,5),(8,1,3),(7,2,9)],构造出来的kd树为:

一棵kd树构造好后,新来一个测试样本后,怎么搜索距离其最近的k个邻居点呢?我们先来看搜索距离其最近的1个邻居点,也就是k=1 k=1k=1,其实也就是最近邻:

kd树的最近邻搜索算法:

  1. 从根节点出发,递归的向下访问kd树,若目标点x xx当前维的坐标小于切分点的坐标,则向左子树移动,否则向右子树移动。直到子结点为叶结点为止。
  2. 以此叶结点为“当前最近点”,此时以目标点为圆心(球心),目标点到此叶结点距离为半径,能够得到一个圆(球体)。
  3. 递归的向上回退,在每个结点进行以下操作:
    检查该结点对应的区域是否与(2)中球体相交,若相交,则该结点对应的区域中可能存在距离目标点更近的点,则移到该结点进行递归的搜索,遇到更近的点更新最短距离(球体半径),否则向上回退。

其实从上面的算法中能够看出,搜索的复杂度就是树的高度,即O(logN) O(logN)O(logN)。

文字总归是空洞的,例子才是生动的,那我们下面来看两个例子,这个两个例子都是基于上面构造的KD树(特征维度为2维的),算了再贴下这个树吧:

例1:
假如,我们要寻找目标点 (3,6) (3,6)(3,6)的最近邻,第一步需要找到目标点所属区域,即找到某个叶结点。 首先从这棵树的根节点 (7,2) (7,2)(7,2)开始遍历,此时在x(1) x(1)x(1)维度,3&lt;7 3&lt;73<7,所以往左走到点(5,4) (5,4)(5,4),此时在x(2) x(2)x(2)维度,6>4 6>46>4,则往右移动到点(4,7) (4,7)(4,7),由于此时点(4,7) (4,7)(4,7)已经是叶结点,因此,认为此叶结点((4,7) (4,7)(4,7))为目标点 (2,6) (2,6)(2,6)的当前最近邻点,点(4,7) (4,7)(4,7)到目标点 (2,6) (2,6)(2,6)的距离为半径,半径为√5 5–√5​,构成了一个圆(如下图所示)。
第二步开始回溯, 回退到点(4,7) (4,7)(4,7)的父结点(5,4)(5,4),由于此时圆与直线 x(2)=4x(2)=4 (若是3维,则为平面)不相交,因此不用遍历点(5,4)(5,4)的左子树了,然后计算下点(5,4)(5,4)与目标点 (3,6)(3,6)的距离,距离为√8>√58​>5​,最短距离保持不变,然后直接回退到点(7,2)(7,2),此时圆与直线 x(1)=7x(1)=7 不相交,因此也不用进入点(7,2)(7,2)的右子树进行搜索,然后仅仅再计算点(7,2)(7,2)与目标点(3,6)(3,6)的距离,距离为√32>√532​>5​,最短距离保持不变,由于点(7,2)(7,2)为树的根节点,因此搜索结束。此时目标点(3,6)(3,6)的最近邻为点(4,7)(4,7),最短距离为√55​。
Note:这与李航《统计学习方法》书中p44 例3.3给的例子有点不同,即若某一区域与圆不相交,则不用去搜素该区域的子区域了,而李航书中例子需要继续搜索,个人认为他的例子这样做是增加了搜索复杂度,是没必要的。可参照:K-D Trees and KNN Searches
另,最最重要的是,如何判断圆是否与某个结点对应的区域是否相交呢?上面的例子是二维空间下的直接判断直线与圆是否相交,三维空间则是判断平面是否与球体相交,因此我们可以知道,只要判断该结点划分维度(即构造kd树时的划分维度,比如是按x(1)x(1)轴还是按x(2)x(2)轴划分的)与目标点对应维度的直线距离是否小于半径即可,比如上面的例子中,我们只需用∣4−6∣=2>√2∣4−6∣=2>2​,即可知道直线 x(2)=4x(2)=4 与圆不相交。三维,四维等高维一样的判断方法。

例2:
下面再举个例子,求目标点(2,5)(2,5)的最近邻点。同样的步骤先找到叶结点,先从根节点(7,2)(7,2)开始遍历,在x(1)x(1)轴上,2&lt;72<7,因此进入点(7,2)(7,2)的左子树,到点(5,4)(5,4),此时在x(2)x(2)维度,5>45>4,则往右移动到点(4,7)(4,7),由于此时点(4,7)(4,7)已经是叶结点,因此,认为此叶结点((4,7)(4,7))为目标点 (2,5)(2,5)的当前最近邻点,点(4,7)(4,7)到目标点 (2,6)(2,6)的距离为半径,即√88​,构成了一个圆(如下图左1所示)。
然后开始回退,回退到点(4,7)(4,7)的父节点(5,4)(5,4),因为∣4−5∣=1&lt;√8∣4−5∣=1<8​,因此圆与直线 x(2)=4x(2)=4 相交,所以进入到点(5,4)(5,4)的左子树继续搜索( 注意,当能进入点(5,4)(5,4)的子区域搜索,此时不需要计算目标点与点(5,4)(5,4)的距离,待待会回溯时再计算),此时点(2,3)(2,3)与目标点(2,5)(2,5)的距离为2,小于√88​,因此,最短距离变为2,此时更新目标点的最近邻点为点(2,3)(2,3),故,点(2,3)(2,3)与目标点形成了新的圆,新半径为2。(如下图右1所示),然后继续回溯到点(5,4)(5,4),计算点(5,4)(5,4)与目标点(2,5)(2,5)的距离为√10>210​>2,最短距离保持不变,因此继续回溯到点(7,2)(7,2),此时直线 x(1)=7x(1)=7 与圆不相交(因为∣2−7∣=5>2∣2−7∣=5>2),故也不用进入点(7,2)(7,2)的右子树进行搜索,然后仅仅计算点(7,2)(7,2)与目标点(2,5)(2,5)的距离为√34>234​>2,由于由于点(7,2)(7,2)为树的根节点,因此搜索结束。此时目标点(2,5)(2,5)的最近邻为点(2,3)(2,3),最短距离为22。

关于求目标点的最近邻点的就讲完了,但是我们通常都是求k近邻啊,即要求目标点最近的kk个点,那该怎么求呢,这不就是topk问题吗?(ps,关于求topk问题,正在实习的熊厂,和猫厂招聘蛮喜欢问的哦,鹅厂当初实习笔试面试时倒没遇到过,比如,给你100亿个数,内存装不下这么多数,让你求前k大的数,你怎么求?准备找工作的小伙伴可以去看看这题哦,额,扯远了,回到正题),topk问题嘛,当然是用个最大(小)堆去记录进来的k个数了,这里是求最近的k个点,因此搜索的时候只要维护个最小堆即可。

这里关于自己实现kd树应该还会遇到个情形,即我们构造了如下一棵kd树,

假如我们要求目标点(2,5)(2,5)的最近邻点,按照上面介绍的流程,发现(5,4)(5,4)没有右孩子了。。叶结点找不到了,那该怎么办,莫慌,此时让其进入左孩子即可。

最后,关于kd树有一点要说的就是,kd树只适用于样本数远大于特征维数的情形,如果当样本数和特征维度差不多的时候,kd树搜索效率几乎和线性扫描差不多。

后记:原打算上周末写完的,结果又拖到了这周。。。




参考文献
[1]: 李航《统计学习方法》
[2]: kubicode博客《KNN算法中KD树的应用
[3]: K-D Trees and KNN Searches