机器学习系列:k 近邻法(k-NN)的原理及实现

  本内容将介绍机器学习中的 k k 近邻法( k k -NN) 的原理及暴力和 k d kd 实现。

一、 k k 近邻算法介绍

   k k 近邻法(K-nearest neighbor, k k -NN)1968 年由 Cover 和 Hart 提出,是一种基本分类与回归方法。使用 k k 近邻法进行分类预测时,对新的实例,根据其 k k 个最近邻的训练实例的类别,通过多数表决法等方式进行预测(这 k k 个实例的多数属于某个类,就把该新实例分为这个类)。因此, k k 近邻法不具有显式的学习过程 k k 近邻法实际上利用训练数据集对特征向量空间进行划分,并作为其分类的“模型”。
   k k 近邻法进行分类和回归预测的主要区别:进行预测时的决策方式不同。进行分类预测时,通常采用多数表决法;而进行回归预测时,通常采用平均法。由于两者区别不大,所以本内容仅介绍使用 k k 近邻进行分类预测。

二、 k k 近邻模型

   k k 近邻法使用的模型实际上对应于对特征空间的划分。模型由三个基本要素——距离度量、 k k 值的选择和分类决策规则决定。

2.1 距离度量

  特征空间中两个实例点的距离是两个实例点相似程度的反映。 k k 近邻模型的特征空间一般是 n n 维实数向量空间 R n \mathbf R^n 。通常使用的距离是欧式距离,但也可以选择其他距离,如更一般的 L p L_p 距离( L p L_p distance)或 Minkowski 距离(Minkowski distance)。
  设特征空间 X \mathcal X n n 维实数向量空间 R n \mathbf R^n x i , x j X x_i,x_j\in\mathbf X x i = ( x i ( 1 ) , x i ( 2 ) ,   , x i ( n ) ) T x_i=(x_i^{(1)},x_i^{(2)},\cdots,x_i^{(n)})^T x i = ( x j ( 1 ) , x j ( 2 ) ,   , x j ( n ) ) T x_i=(x_j^{(1)},x_j^{(2)},\cdots,x_j^{(n)})^T x i , x j x_i,x_j L P L_P 距离定义为
L p ( x i , x j ) = ( l = 1 n x i ( l ) x j ( l ) p ) 1 p L_p(x_i,x_j)=\Big(\sum_{l=1}^{n}|x_i^{(l)}-x_j^{(l)}|^p\Big)^{\frac{1}{p}}
这里 p 1 p\geq1 。当 p = 2 p=2 时,称为欧式距离(Euclidean distance);当 p = 1 p=1 时,称为曼哈顿距离(Manhattan distance)。

2.2 k k 值的选择

   k k 值的选择会对 k k 近邻法的结果产生重大影响。
  如果选择较小的 k k 值,就相当于用较小的邻域中的训练实例进行预测,训练误差会减小,只有与输入实例较近的(相似的)训练实例才会对预测结果起作用。但缺点是泛化误差会增大,预测结果会对近邻的实例点非常敏感。如果邻近的实例点恰巧是噪声,预测就会出错。
  如果选择较大的 k k 值,就相当于用较大的邻域中的训练实例进行预测。其优点是可以减少泛化误差。但缺点是训练会增大。这时与输入实例较远的(不相似的)训练实例也会对预测起作用,是预测发生错误。
  如果 k k = N,那么无论输入什么实例,都将简单地预测它属于在训练实例中最多的类。这是不可取的。
  在应用中, k k 值一般取一个较小的数值(通常为小于 20 的整数)。通常采用交叉验证法来选取最优的 k k 值。

2.3 分类决策规则

   k k 近邻法中的分类决策规则通常采用多数表决法,即由输入实例的 k k 个邻近的训练实例中的多数类决定实例的类。

三、 k k 近邻法的实现

  实现 k k 近邻法时,主要考虑的问题是如何对训练数据进行快速 k k 近邻搜索。

3.1 暴力实现

  首先计算输入实例与训练集中所有实例的距离;然后采用线性扫描方法找出最小的 k k 个距离;最后采用多数表决法进行预测。这种方法非常简单,当训练集样很小时,可以采用。但是当训练集很大时,计算会非常耗时,不能采用。

  Python 代码实现如下:

import numpy as np
import operator


class KNNClassification:
    def __init__(self, method):
        self.method = method
        self.train_data_set = None
        pass

    # 加入测试数据集
    def fit(self, train_data_set):
        self.train_data_set = train_data_set
        pass

    # 对测试实例进行预测
    def predict(self, k, test_data):
        # 计算两点之间的欧式距离
        def euclidean_dist(vec01, vec02):
            mat_vec01 = np.mat(vec01)
            mat_vec02 = np.mat(vec02)
            return np.sqrt((mat_vec01-mat_vec02)*(mat_vec01-mat_vec02).T)

        # 查找出最近的 k 个近邻
        def get_k_nearest_neighbors(train_data_set, k, test_data):
            distances = []
            # 计算测试实例与训练集中的所有实例的欧式距离
            for x in range(len(train_data_set)):
                dist = euclidean_dist(test_data[0], train_data_set[x][0])
                distances.append((train_data_set[x], dist))
            # 进行排序
            distances.sort(key=operator.itemgetter(1))
            neighbors = []
            for x in range(k):
                neighbors.append(distances[x][0])
            return neighbors

        # 从 k 个近邻中返回数量最大的type
        def get_type(neighbors):
            class_votes = {}
            for x in range(len(neighbors)):
                type = neighbors[x][-1]
                if type in class_votes:
                    class_votes[type] += 1
                else:
                    class_votes[type] = 1
            # 排序,对字典 sorted_votes 中的第二个字符进行排序,即对 value 排序
            sorted_votes = sorted(class_votes.items(), key=operator.itemgetter(1), reverse=True)
            return sorted_votes[0][0]

        if self.train_data_set is None:
            return None
        neighbors = get_k_nearest_neighbors(self.train_data_set, k, test_data)
        return get_type(neighbors)


if __name__ == "__main__":
    data_set = np.array([[[2, 3], 1],
                         [[5, 4], 1],
                         [[9, 6], 0],
                         [[4, 7], 1],
                         [[8, 1], 0],
                         [[7, 2], 0]])

    # 实例化一个 KNN 分类器
    KNN = KNNClassification('BruteForce')
    KNN.fit(data_set)
    # 进行预测
    predict_type = KNN.predict(3, [[6, 6], 1])
    print(predict_type)
    predict_type = KNN.predict(3, [[10, 4], 0])
    print(predict_type)

  为了提高 k k 近邻搜索的效率,需要考虑使用特殊的结构存储训练数据。实现的方法有很多,下面将介绍其中的一种, k d kd 树实现

3.2 k d kd 树实现

   k d kd 树是一种对 k k 维空间中的实例点进行存储以便对其进行快速检索的树形数据结构。 k d kd 树是二叉树,表示对 k k 维空间的一个划分(partition)。构造 k d kd 树相当于不断地用垂直于坐标轴的超平面将 k k 维空间划分,构成一系列的 k k 维超矩形区域。 k d kd 树的每个结点对应于一个 k k 维超矩形区域。

3.2.1 构造 k d kd

  构造 k d kd 树的方法:构造根结点,使根结点对应于 k k 维空间中包含所有实例点的超矩形区域;通过下面的递归方法,不断地对 k k 维空间进行切分,生成子结点;当子结点内没有实例时结束。
  具体递归方法:在超矩形区域(父结点)上选择一个坐标轴和在此坐标轴上的一个切分点,确定一个超平面(这个超平面通过选定的切分点并垂直于选定的坐标轴);通过这个超平面将当前超矩形区域分为左右两个子区域(子结点),左子结点对应小于切分点的子区域,右子结点对应大于切分点的子区域,落在超平面上的实例点保存在父结点。
  上面的选定坐标轴,实际上就是选定一个实例特征。坐标轴选择方法:通常是轮流选择所有特征,也可以选择当前超矩形区域中方差最大的特征。切分点选择方法:选择当前超矩形区域中所有实例在选定特征上的中位数(将实例按照选定特征值大小进行排序,出在中间位置的特征值或者中间两个的平均值)。
  假如我们存在这样一个二维空间的数据集: 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\} ,构造 k d kd 树的具体步骤:根结点对应包含数据集 T T 的矩形,选择 x x 轴,中位数为 7,即确定分割平面为 x = 7 x=7 { ( 2 , 3 ) , ( 4 , 7 ) , ( 5 , 4 ) } \{(2,3),(4,7),(5,4)\} 分入左子区域, { ( 8 , 1 ) , ( 9 , 6 ) } \{(8,1),(9,6)\} 分入右子区域。用同样的方法分别划分左右子区域,最终得到如下的特征空间划分和 k d kd 树:

在这里插入图片描述

​ 图:特征空间划分

在这里插入图片描述
​ 图: k d kd

  Python 代码实现如下:

class KdNode:
    def __init__(self, sample, order, parent):
        self.sample = sample  # 保存在该结点中的实例点
        self.order = order
        self.parent = parent
        self.left = None      # 左子结点
        self.right = None    # 右子结点
        pass

    def set_child(self, left, right):
        self.left = left
        self.right = right


class KdTree:
    def __init__(self, date_set):
        self.root = self.create_kd_tree(date_set)
        pass

    # 创建 kd 树
    def create_kd_tree(self, data_set):
        return self.create_node(data_set, 0, None)

    # 创建 kd 树的结点
    def create_node(self, data_set, order, parent):
        if len(data_set) == 0:
            return None
        # 对数据集进行排序
        data_set = sorted(data_set, key=lambda x: x[order])
        # 获取中位数位置
        split_pos = len(data_set) // 2
        median = data_set[split_pos]
        order_next = (order+1) % (len(data_set[0]))

        node = KdNode(median, order, parent)
        left_child = self.create_node(data_set[:split_pos], order_next, node)
        right_child = self.create_node(data_set[split_pos+1:], order_next, node)
        node.set_child(left_child, right_child)
        return node
3.2.2 搜索 k d kd

  下面以最近邻为例进行介绍,同样的方法可以应用到 k k 近邻。在 k d kd 树中搜索最近邻步骤:

  1. k d kd 树中找到包含目标点的叶结点,并将此叶结点保存的实例点作为“当前最近点”。具体查找方法:从根结点出发,递归地向下访问 k d kd 树,若目标点当前维的值小于切分点的值,则移动到左子结点,否则移入右子结点,直到子结点为叶结点为止。
  2. 向上返回到父结点,如果父结点保存的实例点比“当前最近点”距离目标点更近,则将父结点保存的实例点作为“当前最近点”。以目标点为圆心,以目标点到“当前最近点”的距离为半径,得到一个超球体,检查父结点的另一个子结点对应的超矩形区域是否与此超球体相交。如果相交,就在这个子结点中查找是否存在更近的实例点,如果有,就更新“当前最近点”。
  3. 不断执行步骤 2,直到返回到父结点结束。

  通过上面的描述,我们可以看到,利用 k d kd 树可以省去对大部分实例点的搜索,从而减少搜索的计算量。
  如果实例点是随机分布的, k d kd​ 树搜索的平均计算复杂度是 O ( l o g N ) O(logN)​ ,这是 N N​ 是训练实例树。 k d kd​ 树更适用于训练是隶属远大于空间维数时的 k k​ 近邻搜索。当空间维数接近训练实例树时,它的效率会迅速下降,几乎接近线性扫描。

参考:

[1] 李航《统计学习方法》

[2] https://www.cnblogs.com/pinard/p/6061661.html

[3] https://www.cnblogs.com/21207-iHome/p/6084670.html

[4] https://blog.csdn.net/gamer_gyt/article/details/51232210

猜你喜欢

转载自blog.csdn.net/benzhujie1245com/article/details/83098410
今日推荐