有监督学习 —— KNN算法

1、KNN简介

KNN算法,中文名称为最近邻算法,k-近邻法是一种基本分类与回归算法。和其它有监督算法不同,KNN算法是一种“惰性”学习算法,即不会预先生成一个分类或预测模型,用于新样本的预测,而是将模型的构建与未知数据的预测同时进行。

KNN算法和决策树类似,即可以针对离散因变量做分类,又可以对连续因变量做预测,其核心思想就是比较已知y值的样本和未知y值样本的相似度,然后寻找最相似的k个样本用作未知样本的预测。

k值的选择、距离度量及分类决策规则是2k近邻法的三个基本要素,k-近邻法于1968年由Cover和Hart提出。

1.1 KNN算法优缺点

  • 优点:精度高、对异常值不敏感(由k个最近邻数据决定类别)、无数据输入假定(不需要输入数据符合某种要求);
  • 缺点:计算复杂度高(需要计算所有数据得到相互间的距离)、空间复杂度高(如果训练数据集很大,必须用大量的存储空间);无法给出任何数据的基础结构信息,无法知晓平均样例和典型实例样本具有什么特征;
  • 适用数据范围:数值型和标称型;
  • KNN算法并不会输出待预测数据属于某一类别的概率,而是直接输出其属于哪一个类别;

2、KNN算法的思想

K最近邻算法,顾名思义就是搜寻最近的k个已知类别样本用于未知类别样本的预测。

  1. “最近”的度量就是应用点之间的距离或者相似度。距离越小相似度越高,说明他们之间越近。
  2. “预测”,对于离散型的因变量来说,从k个最近的已知类别样本中挑选出频率最高的类别用于未知样本的判断;对于连续型的因变量来说,则是将k个最近的已知样本均值用作未知样本的预测。

KNN算法的具体步骤可以描述为:

  1. 确定未知样本近邻的个数k值;
  2. 根据某种度量样本间相似度的指标(如欧式距离)将每一个未知类别样本的最近k个已知样本搜寻出来,形成一个个簇;
  3. 对搜寻出来的已知样本进行投票,将各簇下类别最多的分类用作未知样本点的预测;

如果存在两个已知类别样本到未知类别样本的距离一样,但是两个已知类别样本的类别是矛盾的,则根据已知类别出现的先后顺序决定选择哪个。

KNN算法也能用于回归问题,假设离测试样本最近的k个训练样本的标签值为 y i y_i yi,则对样本的回归预测输出值为 y ^ = ( ∑ i = 1 k y i ) / k \hat{y}=(\sum_{i=1}^ky_i)/k y^=(i=1kyi)/k即所有邻居的标签均值,在这里最近的k个邻居的贡献被认为是相等的。同样也可以采用带权重的方案。带样本权重的回归预测函数为 y ^ = ( ∑ i = 1 k w i y i ) / k \hat{y}=(\sum_{i=1}^kw_iy_i)/k y^=(i=1kwiyi)/k其中, w i w_i wi为第 i i i个样本的权重,权重值可以人工设定,或者用其它方法来确定。例如,设置权重为与距离成反比。

通过上面步骤,能够理解为什么该算法被称为“惰性”算法,如果该算法仅仅接受已知类别的样本点,它是不会进行模型运算的,只有将未知类别样本加入到已知类别样本中,才会执行搜寻工作,并将最终的分类结果返回。

3、最佳K值的选择

根据经验发现,不同的K值对模型的预测准确性会有较大的影响,如果K值过于偏小,可能会导致模型的过拟合;如果K值过大,有可能会使模型进入欠拟合状态。

这个举个例子说明一下:

  1. 假设K值为1时,意味着未知样本点的类别将由最近的1个已知样本点决定,投票功能将不再起效。对于训练样本集本身来说,其训练误差几乎为0;但是对于未知的测试数据集来说,训练误差可能会很大,因为距离最近的1个已知样本点可以是异常观测也可以是正常观测。所以K值过于偏小可能会导致模型的过拟合。
  2. 假设K值为N,意味着未知样本点的类别将由所有已知样本点中频数最高的类别所决定。所以不管是训练数据集还是测试数据集,都会被判为一种类别,进而导致模型无法在训练数据集和测试数据集上得到理想的准确率。进而可以说明,K值越大,模型偏向于欠拟合的可能性就越大。

为了获得最佳的K值,可以考虑两种解决方案:

  1. 设置K近邻样本的投票权重,假设在使用KNN算法进行分类或预测时设置的K值比较大,担心模型发生欠拟合的现象,一个简单有效的处理办法就是设置近邻样本的投票权重,如果已知样本距离未知样本距离比较远,则对应的权重就设置地低一些,否则权重就设置得高一些,通常可以设置为距离的倒数。
  2. 采用多重交叉验证法,该方法是目前比较流行的方案,其核心是将K取不同的值,然后在每种值下执行m重的交叉验证,最后选出平均误差最小的k值。
  3. 当然还可以将两种方法的优点相结合,选出理想的k值。

4、相似度的度量方法

KNN分类算法的思想是计算位置分类的样本点与已知分类的样本点之间的距离,然后将位置分类最近的K个已知分类样本用作投票。所以该算法的一个重要步骤是计算他们之间的相似度。下面简单介绍一下欧氏距离,曼哈顿距离,然后拓展两种相似度的度量指标,一个是余弦相似度,另一个是杰卡德相似系数。

4.1 距离定义

KNN算法的实现依赖于样本之间的距离值,因此,需要定义距离的计算公式。两个向量之间的距离为 d ( x i , x j ) d(x_i,x_j) d(xi,xj),这是一个将两个维数相同的向量映射为一个实数的函数。距离函数必须满足以下条件:

  1. 三角不等式: d ( x i , x k ) + d ( x k , x j ) ≥ d ( x i , x j ) d(x_i,x_k)+d(x_k,x_j)\geq d(x_i,x_j) d(xi,xk)+d(xk,xj)d(xi,xj)这与几何中的三角不等式吻合;
  2. 非负性,即距离不能是一个负数: d ( x i , x j ) ≥ 0 d(x_i,x_j)\geq 0 d(xi,xj)0
  3. 对称性,即A到B的距离和B到A的距离必须相等: d ( x i , x j ) = d ( x j , x i ) d(x_i,x_j)=d(x_j,x_i) d(xi,xj)=d(xj,xi)
  4. 区分性,如果两点间的距离为0,则两个点必须相同: d ( x i , x j ) = 0 ⇒ x i = x j d(x_i,x_j)=0\Rightarrow x_i=x_j d(xi,xj)=0xi=xj

满足上面4个条件的函数都就可以用作距离定义

4.2 欧式距离

该距离度量的是两点之间的直线距离,如果二维平面中存在两点 A ( x 1 , y 1 ) A(x_1,y_1) A(x1,y1) B ( x 2 , y 2 ) B(x_2,y_2) B(x2,y2),则它们之间的直线距离为: d A , B = ( x 1 − x 2 ) 2 + ( y 1 − y 2 ) 2 d_{A,B}=\sqrt{(x_1-x_2)^2+(y_1-y_2)^2} dA,B=(x1x2)2+(y1y2)2 该公式的几何意义实际上就是下图中的直角三角形的斜边。
在这里插入图片描述
如果将点扩展到n维空间,则点 A ( x 1 , x 2 , . . . , x n ) A(x_1,x_2,...,x_n) A(x1,x2,...,xn) B ( y 1 , y 2 , . . . , y n ) B(y_1,y_2,...,y_n) B(y1,y2,...,yn)之间的欧式距离可以表示成: d A , B = ( y 1 − x 1 ) 2 + ( y 2 − x 2 ) 2 + . . . + ( y n − x n ) 2 d_{A,B}=\sqrt{(y_1-x_1)^2+(y_2-x_2)^2+...+(y_n-x_n)^2} dA,B=(y1x1)2+(y2x2)2+...+(ynxn)2

4.3 曼哈顿距离

该距离也称为“曼哈顿街区距离”,度量的是两点在轴上的相对距离总和。所以,二维平面中两点 A ( x 1 , y 1 ) A(x_1,y_1) A(x1,y1) B ( x 2 , y 2 ) B(x_2,y_2) B(x2,y2)之间的曼哈顿距离可以表示成: d A , B = ∣ x 1 − x 2 ∣ + ∣ y 1 − y 2 ∣ d_{A,B}=|x_1-x_2|+|y_1-y_2| dA,B=x1x2+y1y2其具体几何意义可以看一下下面的图:
在这里插入图片描述
同样地,如果将点扩展到n维空间中,则点 A ( x 1 , x 2 , . . . , x n ) A(x_1,x_2,...,x_n) A(x1,x2,...,xn) B ( y 1 , y 2 , . . . , y n ) B(y_1,y_2,...,y_n) B(y1,y2,...,yn)之间的曼哈顿距离可以表示成: d A , B = ∣ y 1 − x 1 ∣ + ∣ y 2 − x 2 ∣ + . . . + ∣ y n − x n ∣ d_{A,B}=|y_1-x_1|+|y_2-x_2|+...+|y_n-x_n| dA,B=y1x1+y2x2+...+ynxn

4.4 余弦相似度

该相似度是计算两点所构成向量夹角的余弦值,夹角越小,则余弦值越接近于1,进而能够说明两点之间越相似。对于二维平面中的两点 A ( x 1 , y 1 ) A(x_1,y_1) A(x1,y1) B ( x 2 , y 2 ) B(x_2,y_2) B(x2,y2)来说,它们之间的余弦相似度可以表示成: S i m i l a r i t y A , B = C o s θ = x 1 x 2 + y 1 y 2 x 1 2 + y 1 2 + x 2 2 + y 2 2 Similarity_{A,B}=Cos\theta=\frac{x_1x_2+y_1y_2}{\sqrt{x_1^2+y_1^2}+\sqrt{x_2^2+y_2^2}} SimilarityA,B=Cosθ=x12+y12 +x22+y22 x1x2+y1y2 A ( x 1 , y 1 ) A(x_1,y_1) A(x1,y1) B ( x 2 , y 2 ) B(x_2,y_2) B(x2,y2)两点构成向量的夹角构成的图如下所示,就能够理解夹角越小,两点越相似的结论。
在这里插入图片描述
假设A、B代表两个用户从事某件事的意愿,意愿程度的大小用各自的夹角 θ 1 \theta_1 θ1 θ 2 \theta_2 θ2表示,两个夹角之差越小,说明两者的意愿方向越一致,进而他们的相似度越高(不管是相同的高意愿还是低意愿)。

如果将点扩展到n维空间,则点 A ( x 1 , x 2 , . . . , x n ) A(x_1,x_2,...,x_n) A(x1,x2,...,xn) B ( y 1 , y 2 , . . . , y n ) B(y_1,y_2,...,y_n) B(y1,y2,...,yn)之间的余弦相似度可以用向量表示为 S i m i l a r i t y A , B = C o s θ = A . B ∣ ∣ A ∣ ∣ ∣ ∣ B ∣ ∣ Similarity_{A,B}=Cos\theta=\frac{A.B}{||A||||B||} SimilarityA,B=Cosθ=ABA.B其中, . . .代表两个向量之间的内积,符号 ∣ ∣   ∣ ∣ ||\space||  代表向量的模,即L2正则化。

4.5 杰卡德相似系数

该相似系数与余弦相似度经常被用于推荐算法,计算用户之间的相似度。例如,用户A购买了10件不同的商品,B用户购买了15件不同的商品,则两者之间的相似系数可以表示为: J ( A , B ) = ∣ A ⋂ B ∣ ∣ A ⋃ B ∣ J(A,B)=\frac{|A\bigcap B|}{|A\bigcup B|} J(A,B)=ABAB其中, ∣ A ⋂ B ∣ |A\bigcap B| AB表示两个用户所购买相同商品的数量, ∣ A ⋃ B ∣ |A\bigcup B| AB代表两个用户购买的所有商品的数量。杰卡德相似稀疏越大,代表样本之间越接近。

使用距离方法来度量样本间的相似度,必须注意两点,一个是所有变量的数值化,如果某些变量为离散型的字符串,它们是无法计算距离的,需要对其做数值化处理,如构造哑变量或强制数值编码(例如将受教育水平的高中、大学、硕士及以上三种离散值重编码为0,1,2);另一个是防止数值变量的量纲影响,在实际项目的数据中,不同变量的数值范围可能是不一样的,这样就会使计算的距离值收到影响,所以必须采用数据的标准化方法对其归一化,使得所有变量的数值具有可比性。

在确定好某种距离计算公式后,KNN算法就开始搜寻最近的K个已知类别样本点。实际上,该算法在搜寻过程中是非常耗内存的,因为它需要不停地比较每一个未知样本与已知样本之间的距离。因此在暴力搜寻法的基础上,提出一些效率提升的算法,如KD树搜寻法和球树搜寻法,使用不同的搜寻方法往往会提升模型的执行效率。

5、K-近邻的分类决策规则

k近邻法中的分类决策规则往往是多数表决,即由驶入实例的k个近邻的训练实例中的多数类决定输入实例的类;

多数表决规则有以下解释:如果分类的损失函数为0-1损失函数,分类函数为 f : R n → { c 1 , c 2 , . . . , c K } f:R^n\rightarrow \{c_1,c_2,...,c_K\} f:Rn{c1,c2,...,cK}那么误分类的概率是 P ( Y ≠ f ( X ) ) = 1 − P ( Y = f ( X ) ) P(Y\neq f(X))=1-P(Y=f(X)) P(Y=f(X))=1P(Y=f(X))对给定的实例 x ∈ X x\in X xX,其最近邻的k个训练实例点构成集合 N k ( x ) N_k(x) Nk(x),如果涵盖 N k ( x ) N_k(x) Nk(x)的区域类别是 c j c_j cj,那么误分类率是 1 k ∑ x i ∈ N k ( x ) I ( y i ≠ c j ) = 1 − 1 k ∑ x i ∈ N k ( x ) I ( y i = c j ) \frac{1}{k}\sum_{x_i\in N_k(x)}I(y_i\neq c_j)=1-\frac{1}{k}\sum_{x_i\in N_k(x)}I(y_i=c_j) k1xiNk(x)I(yi=cj)=1k1xiNk(x)I(yi=cj)钥匙误分类率最小即经验风险最小,就要使 ∑ x i ∈ N k ( x ) I ( y i = c j ) \sum_{x_i\in N_k(x)}I(y_i=c_j) xiNk(x)I(yi=cj)最大,所以多数表决规则等价于经验风险最小化。

6、KNN算法从零实现(基于Python)

6.1 伪代码

对未知类别属性的数据集中的每个点依次执行以下操作:

  1. 计算已知类别数据集中的点与当前点之间的距离;
  2. 按照距离递增次序排序;
  3. 选取与当前点距离最小的k个点;
  4. 确定前k个点所在类别的出现频率;
  5. 返回前k个点出现频率最高的类别作为当前点的预测分类;

6.2 Python代码实现

方法(一)

from numpy import *
import operator


def GetData():  # 测试用例函数
    dataset = array([[1.0, 1.1], [1.0, 1.0], [0, 0], [0, 0.1]])
    label = ['A', 'A', 'B', 'B']
    return dataset, label


def knn(index, dataset, label, k):
	# index: 待预测的数据
	# dataset: 训练数据
	# label: 训练数据的标签
	# k: 与待预测数据距离最近的k个训练数据 
    datasetSize = dataset.shape[0]  # 计算训练数据的数据量
    diffMat = tile(index, (datasetSize, 1)) - dataset  # tile用于将待预测数据按行复制,复制的行数为训练数据的个数,得到array数组与dataset训练数据进行相减,得到待预测数据与所有训练数据之间的差值
    sqDiffMat = diffMat ** 2  # 对上面计算得到的差值进行平方
    sqDistances = sqDiffMat.sum(axis=1)  # 对平方后的距离按行进行求和
    distance = sqDistances ** 0.5  # 使用欧式距离,计算得到距离的平方和后需要开方
    sortedDistIndices = distance.argsort()  # argsort()的作用是按距离值的大小从小到大返回对应的索引
    classCount = {}  # 建立一个字典用于保存K个数据的标签数量
    
    for i in range(k):  # 循环k次,因为只需要最近邻的k个训练数据
        voteLabel = label[sortedDistIndices[i]]  # 根据得到的距离最小的K个索引找到对应的K个训练数据的标签
        classCount[voteLabel] = classCount.get(voteLabel, 0) + 1  # get()函数是针对字典使用的,当字典中没有voteLabel关键字时,在字典中创建关键字voteLabel,并初始化为0。如果存在,则忽略,直接在原来基础上进行加一操作;
        
    sortedClassCount = sorted(classCount.items(), key=lambda x: x[1], reverse=True)  # Python 字典(Dictionary) items() 函数以列表返回可遍历的(键, 值) 元组数组,使用sorted()函数对列表进行排序,排序的依据是根据键值对的值从大往小进行排序
    return sortedClassCount[0][0]  # 返回列表中键值对值最大的元组对应的键


if __name__ == "__main__":  # 测试KNN代码可行性
    group, labels = GetData()
    print(knn([0.0, 0.0], group, labels, 3))

代码中有三部分值得学习:

  1. tile()函数,作用是将数据根据训练数据的大小按行进行复制,这样可以直接对整个训练数据进行距离计算;
  2. argsort()函数,作用是根据数组中值的大小返回排序后的序列,比如数组中的数据为[1.3,1.1,1.5],使用argsort()返回的是[1,0,2],就是排序后返回其数据原来的索引位置;
  3. items()函数,作用是将字典的键值对转换为列表的键值对元组数组形式,这样就可以针对其标签数量进行排序,返回出现次数最多的标签;

方法(二)

# 代码来源:https://github.com/fengdu78/lihang-code
class KNN:
    def __init__(self, X_train, y_train, n_neighbors=3, p=2):
        """
        parameter: n_neighbors 邻近点个数
        parameter: p 距离度量
        """
        self.n = n_neighbors  # 邻近点个数,默认为3
        self.p = p  # 距离度量范数。默认为2
        self.X_train = X_train  # 训练数据
        self.y_train = y_train  # 训练数据label

    def predict(self, X):
        # 取出n个点
        knn_list = []
        for i in range(self.n): # 遍历X_train的前n个数
            dist = np.linalg.norm(X - self.X_train[i], ord=self.p)  # 等价于求X-self.X_train[i]的欧氏距离
            knn_list.append((dist, self.y_train[i]))  # 将X_train[i]和X的距离,X_train[i]的label保存到knn_list中

        for i in range(self.n, len(self.X_train)):  # 遍历X_train的剩余数据
            max_index = knn_list.index(max(knn_list, key=lambda x: x[0]))  # 找到knn_list中与X距离最远数据的index
            dist = np.linalg.norm(X - self.X_train[i], ord=self.p)  # 计算当前数据X_train[i]与X的距离
            if knn_list[max_index][0] > dist:  # 如果dist小于最远距离
                knn_list[max_index] = (dist, self.y_train[i])  # 更新knn_list

        # 统计不同label的数量和种类
        knn = [k[-1] for k in knn_list]  
        count_pairs = Counter(knn)
        max_count = sorted(count_pairs.items(), key=lambda x: x[1])[-1][0]
        return max_count

    def score(self, X_test, y_test):
        right_count = 0
        for X, y in zip(X_test, y_test):  # 遍历每个测试集数据
            label = self.predict(X)  # 预测结果比较
            if label == y:
                right_count += 1
        return right_count / len(X_test)  # 计算准确度

7、近邻样本的搜寻方法

搜寻的实际就是计算比比较未知样本和已知样本之间的距离,最简单粗暴的方法是全表扫描,该方法被称为暴力搜寻法。

例如,针对某个未知类别的测试样本,需要计算它与所有已知类别的样本点之间的距离,然后从中挑选出最近的k个样本,再基于这k个样本进行投票,将票数最多的类别用作未知样本的预测。该方法简单而直接,但是该方法只能适合小样本的数据集,一旦数据集的变量个数和观测个数较大时,KNN算法的执行效率就会非常低下。其运算过程就相当于使用了两层for循环,不仅要迭代每一个未知类别的样本,还需要迭代所有已知类别的样本。

为了避免全表扫描,提出了KD搜索树和球形搜寻法。每个算法的出现必定有其意义,只有知道其出现的意义才能更好地理解。下面我们介绍一下这两种提高KNN执行效率的搜寻方法:

7.1 KD搜寻树

KD树的英文名称为K-Dimension Tree,是一种二分支的树结构,这里的K表示训练集中包含的变量个数,而非KNN模型中的K个近邻样本。其最大的搜寻特点是先利用所有已知类别的样本点构造一个树模型,然后将未知类别的测试集应用在树模型上,实现最终的预测功能。先建树后预测的模式,能够避免全表扫描,提高KNN模型的运行速度。KD树搜寻法包含两个重要的步骤,第一个步骤是如何构建一颗二叉树,第二个步骤是如何实现最近邻的搜寻。

7.1.1 KD树的构建

实现k-近邻法时,主要考虑的问题是如何对训练数据进行快速的k近邻搜索。这在特征空间的维度大及训练数据容量大时尤其重要;

k近邻算法最简单的实现方法是线性扫描,这时要计算输入实例与每一个训练实例的距离。当训练集很大时,计算非常耗时,这种方法不可行。

为了提高k近邻搜索的效率,可以考虑使用特殊的结构存储训练数据,以减少计算距离的次数,下面介绍kd 树方法。

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

构造kd树的方法如下:
构造根节点,使根节点对应于k维空间中包含所有实例点的超矩形区域;通过下面的递归方法,不断地对k维空间进行切分,生成子节点。在超矩形区域(结点)上选择一个坐标轴和在此坐标轴上的一个切分点,确定一个超平面,这个超平面通过选定的切分点并垂直于选定的坐标轴,将当前超矩形区域切分为左右两个子区域(子结点);这时,实例被分到两个子区域,这个过程直到子区域内没有实例时终止(终止时的结点为叶节点)。在此过程中,将实例保存在相应的结点上。

通常,依次选择坐标轴对空间切分,选择训练实例点在选定坐标轴上的中位数为切分点,这样得到的kd树是平衡的,注意,平衡的kd树搜索时的效率未必是最优的。

构造平衡kd树伪代码

输入:k维空间数据集 T = { x 1 , x 2 , . . . , x N } T=\{x_1,x_2,...,x_N\} T={x1,x2,...,xN},其中 x i = { x i ( 1 ) , x i ( 2 ) , . . . , x i ( k ) } T , i = 1 , 2 , . . . , N x_i=\{x_i^{(1)},x_{i}^{(2)},...,x_i^{(k)}\}^T,i=1,2,...,N xi={xi(1),xi(2),...,xi(k)}T,i=1,2,...,N
输出:kd树;

  1. 开始:构造根结点,根结点对应于包含T的k维空间的超矩形区域;选择 x ( 1 ) x^{(1)} x(1)作为坐标轴,以T中所有实例的 x ( 1 ) x^{(1)} x(1)坐标的中位数为切分点,将根结点对应的超矩形区域切分为两个子区域,切分由通过切分点并与坐标轴 x ( 1 ) x^{(1)} x(1)垂直的超平面实现;

    由根结点生成深度为1的左、右子结点:左子节点对应坐标 x ( 1 ) x^{(1)} x(1)小于切分点的子区域,右子结点对应于坐标 x ( 1 ) x^{(1)} x(1)大于切分点的子区域;

    将落在切分超平面上的实例点保存在根结点。

  2. 重复:对深度为j的结点,选择 x ( l ) x^{(l)} x(l)作为切分的坐标轴, l = j ( m o d   k ) + 1 l=j(mod\space k)+1 l=j(mod k)+1,以该结点的区域中所有实例的 x ( l ) x^{(l)} x(l)坐标为切分点,将该结点对应的超矩形区域切分为两个子区域。切分由通过切分点并与坐标轴 x ( l ) x^{(l)} x(l)垂直的超平面实现。

    由该结点生成深度为 j + 1 j+1 j+1的左、右子结点:左子结点对应坐标 x ( l ) x^{(l)} x(l)小于切分点的子区域,右子结点对应坐标 x ( l ) x^{(l)} x(l)大于切分点的子区域。

    将落在切分超平面上的实例点保存在该结点;

  3. 直到两个子区域没有实例存在时停止,从而形成kd树的区域划分;

其Python代码实现如下:

# 代码来源:百度百科(https://baike.baidu.com/item/kd-tree/2302515?fr=aladdin)
from numpy import *

class KDNode(object):
    def __init__(self, value, split, left, right):
        # value=[x,y]
        self.value = value  # value是当前结点的数据
        self.split = split  # split用于保存切分的特征
        self.right = right  # 保存左子树
        self.left = left  ¥ 保存右子树
  
  
class KDTree(object):
    def __init__(self, data):
        # data=[[x1,y1],[x2,y2]...,]
        # 维度
        k = len(data[0])  # x的特征个数
  
        def CreateNode(split, data_set):  # split用于选择切分特征,data_set是要构建kd树的数据
            if not data_set:  # 如果数据集为空,返回None,用于停止kd树的构建
                return None
            data_set.sort(key=lambda x: x[split])  # 在数据x的split维度进行排序
            # 整除2
            split_pos = len(data_set) // 2  # 选择中间的数据进行划分
            median = data_set[split_pos]  # 获得划分的节点数据
            split_next = (split + 1) % k  # 切分特征进行更新
  
            return KDNode(median, split, CreateNode(split_next, data_set[: split_pos]),
                          CreateNode(split_next, data_set[split_pos + 1:]))  # 返回构造完成的kd树,这里对左右子树同样使用CreateNode进行子树构建
  
        self.root = CreateNode(0, data)  # 开始构建kd树,以第一个特征为初始划分split    

7.1.2 KD树的搜寻

当一个未知类别的样本进入到KD树后,就会自顶向下地流淌到对应的叶子结点,并开始反向计算最近邻的样本。有关KD树的搜寻步骤可以描述为:

  1. 将测试集中某个数据点与当前结点(例如根结点或者某个中间结点)所在轴的数据进行运算比较,如果位置类别的样本点所对应的轴数据小于等于当前结点的轴数据,则将该测试点流入当前结点的左侧子结点中,否则流入当前结点的右侧子结点中;
  2. 重复步骤1,直到未知类别的样本点落入对应的叶节点中,此时从叶节点中搜寻到“临时”的最近邻点,然后以未知类别的测试点为中心,以叶节点中的最近距离为半径,构成球体;
  3. 按照起始流淌的顺序原路返回,从叶节点返回到上一层的父结点,检查步骤2中的球体是否与父结点构成的分割线相交,如果相交,需要从父结点和对应的另一侧叶节点中重新搜寻最近邻点;
  4. 如果在步骤3中搜寻到比步骤2中的半径还小的新样本,则将其更新为当前最近邻点,并重新构造球体。否则,就返回父结点的父结点,重新检查球体是否与分割线相交;
  5. 不断重复迭代步骤3和步骤4,当寻找出来的球体与分割线都没有相交时则停止迭代;最终从所有已知类别的样本中搜寻出最新的近邻样本;
# 代码来源:百度百科(https://baike.baidu.com/item/kd-tree/2302515?
from numpy import *

class KDNode(object):
    def __init__(self, value, split, left, right):
        # value=[x,y]
        self.value = value  # value是数据
        self.split = split  # split是数据划分的特征所在维度值
        self.right = right
        self.left = left
  
  
class KDTree(object):
    def __init__(self, data):
        # data=[[x1,y1],[x2,y2]...,]
        # 维度
        k = len(data[0])
  
        def CreateNode(split, data_set):
            if not data_set:
                return None
            data_set.sort(key=lambda x: x[split])  # 在数据x的split维度进行排序
            # 整除2
            split_pos = len(data_set) // 2  # 选择中间的数据进行划分
            median = data_set[split_pos]  # 获得划分的节点数据
            split_next = (split + 1) % k
  
            return KDNode(median, split, CreateNode(split_next, data_set[: split_pos]),
                          CreateNode(split_next, data_set[split_pos + 1:]))
  
        self.root = CreateNode(0, data)
         
    def search(self, root, x, count=1):  # KD树的搜寻代码实现
        # root为kd树的根结点
        # count是寻找离x最近的count个数据
        nearest = []  # nearest用于保存最近的count个点的信息
        for i in range(count):
            nearest.append([-1, None])
        self.nearest = np.array(nearest)
  
        def recurve(node):
            if node is not None:
                axis = node.split  # 获得当前结点的划分特征
                daxis = x[axis] - node.value[axis]  # 在对应维度进行判断
                if daxis < 0:  # x[axis] < node.value[axis]
                    recurve(node.left)  # 数据进入左子树 
                else:
                    recurve(node.right)  # 数据进入右子树
                dist = sqrt(sum((p1 - p2) ** 2 for p1, p2 in zip(x, node.value)))  # 计算两点之间的欧式距离
                for i, d in enumerate(self.nearest):
                    if d[0] < 0 or dist < d[0]:  # 如果当前nearest内i处未标记(-1),或者新点与x距离更近
                        self.nearest = np.insert(self.nearest, i, [dist, node.value], axis=0)  # 插入比i处距离更小的
                        self.nearest = self.nearest[:-1]  # 插入一个数据就得删除一个数据
                        break  # 当找到插入位置,则打断循环
                # 找到nearest集合里距离最大值的位置,为-1值的个数
                n = list(self.nearest[:, 0]).count(-1)
                print(list(self.nearest[:,0]),list(self.nearest[:,0]).count(-1))
                # 切分轴的距离比nearest中最大的小(存在相交)
                if self.nearest[-n - 1, 0] > abs(daxis):  # 存在某个点与父结点的切分轴有交点
                    if daxis < 0:  # 相交,x[axis]< node.data[axis]时,去右边(左边已经遍历了)
                        recurve(node.right)
                    else:  # x[axis]> node.data[axis]时,去左边,(右边已经遍历了)
                        recurve(node.left)
        recurve(root)
        return self.nearest  # 返回距离x最近的count个点的信息
  
  
# 最近坐标点、最近距离和访问过的节点数
# result = namedtuple("Result_tuple", "nearest_point nearest_dist nodes_visited")
  
data = [[2, 3], [5, 4], [9, 6], [4, 7], [8, 1], [7, 2]]
kd = KDTree(data)
  
#[3, 4.5]最近的3个点
n = kd.search(kd.root, [3, 4.5], 3)
print(n)
  
#[[1.8027756377319946 list([2, 3])]
#  [2.0615528128088303 list([5, 4])]
#  [2.692582403567252 list([4, 7])]]

简单理解上面的代码,已有构建好的kd树,目的是寻找kd树中与数据x欧式距离最近的count个数据;从根结点开始进行遍历,找到与x距离最近的叶子结点,计算叶子结点中与x欧氏距离最近的点,并保存两点之间的距离;

更新nearset中距离最近的count个点的信息,如果当前结点与x的距离与划分轴有交叉,则对其左/右子树进行遍历。

8、球树搜寻法

尽管kd树搜寻法相比于暴力搜寻法要快得多,但是该方法在搜寻分布不均匀的数据集时,效率会下降很多,因为根据结点切分的超矩形体都含有“角”。如果构成的球体与“角”相交,必然会使搜寻路径扩展到“角”相关的超矩形体内,从而增加搜寻的时间。
在这里插入图片描述

球树搜寻法之所以能够解决kd树的缺陷,是因为球树将kd树中的超矩形体换成了超球体,没有了角,就不容易产生模棱两可的区域。对比球树的构造和搜寻过程,会发现与kd树的思想非常相似,所不同的是,球体的最优搜寻路径复杂度提高了,但是可以避免很多无谓样本点的搜寻。

8.1 球体的构造

不同的超球体囊括了对应的样本点,超球体相当于树中的结点,所以构造球体的过程就是构造树的过程,关键点在于球心的寻找和半径的计算。

球树的构造如下

  1. 首先构建一个超球体,这个超球体的球心就是某线段的中心,而该线段就是求内所有训练样本点中两辆距离最远的线段,半径就是最远距离的一半,从而得到的超球体就是囊括所有样本点的最小球体;
  2. 然后从超球体内寻找离球心最远的点 p 1 p_1 p1,接着寻找离点 p 1 p_1 p1最远的点 p 2 p_2 p2,以这两个点为簇心,通过距离的计算,将剩余的样本点划分到对应的簇中心,从而得到两个数据块;
  3. 最后重复步骤1,将步骤2中的两个数据块构造成对应的最小球体,直到球体无法继续划分为止;

从上面的步骤可知,球树的根结点就是囊括所有训练数据集的最小超球体,根结点的两个子结点就是由步骤2中两个数据块构成的最小超球体。以此类推,可以不停地将数据划分到对应的最小超球体中,最终形成一颗球树;

8.2 球树的搜寻

球树在搜寻最近邻样本时与KD树非常相似,下面详细介绍球树在搜寻过程中的具体步骤:

  1. 从球树的顶端到底端,寻找能够包含未知类别样本点所属的叶节点,并从叶结点的球体中寻找离未知类别样本点最近的点,得到相应的最近距离d;
  2. 回流到另一支的叶结点中,此时不再比较未知类别样本点与叶结点中的其它样本点之间的距离,而是计算未知类别样本点与叶结点对应的球心距离D;
  3. 比较距离d、D和步骤2中叶结点球体的半径r,如果 D > d + r D>d+r D>d+r,则说明无法从叶结点中找到未知类别样本点更近的点;如果 D < d + r D<d+r D<d+r,则需要回流到上一层父结点所对应的球体,并从球体中搜寻更近的样本点;
  4. 重复步骤2和3,直到回流至根结点,最终搜寻到离未知类别样本点最近的样本;

球树搜寻法的Python代码实现:

# 代码来源于https://github.com/qzq2514/KDTree_BallTree
import numpy as np
import pandas as pd
from collections import Counter
import time

allow_duplicate = False
def load_data(csv_path):
    data = pd.read_csv(csv_path,sep=";")
    # data = data.sample(frac=1)
    # data = data.reset_index(drop=True)
    label = data["quality"]
    data = data.drop(["quality"], axis=1)
    return data.values,label,data.columns.values

class Ball():
    def __init__(self,center,radius,points,left,right):
        self.center = center  #使用该点即为球中心,而不去精确地去找最小外包圆的中心
        self.radius = radius  # 球半径
        self.left = left  # 左子球体
        self.right = right  # 右子球体
        self.points = points

class BallTree():
    def __init__(self,values,labels):
        self.values = values  # 训练数据
        self.labels = labels  # 训练数据的label
        if(len(self.values) == 0 ):
            raise Exception('Data For Ball-Tree Must Be Not empty.')
        self.root = self.build_BallTree()
        self.KNN_max_now_dist = np.inf  # 距离为无穷大
        self.KNN_result = [(None,self.KNN_max_now_dist)]

    def build_BallTree(self):
        data = np.column_stack((self.values,self.labels))
        return self.build_BallTree_core(data)

    def dist(self,point1,point2):
        return np.sqrt(np.sum((point1-point2)**2))

    #data:带标签的数据且已经排好序的
    def build_BallTree_core(self,data):
        if len(data) == 0:
            return None
        if len(data) == 1:
            return Ball(data[0,:-1],0.001,data,None,None)
        #当每个数据点完全一样时,全部归为一个球,及时退出递归,不然会导致递归层数太深出现程序崩溃
        data_disloc =  np.row_stack((data[1:],data[0]))
        if np.sum(data_disloc-data) == 0:
            return Ball(data[0, :-1], 1e-100, data, None, None)
        cur_center = np.mean(data[:,:-1],axis=0)     #当前球的中心
        dists_with_center = np.array([self.dist(cur_center,point) for point in data[:,:-1]])     #当前数据点到球中心的距离
        max_dist_index = np.argmax(dists_with_center)        #取距离中心最远的点,为生成下一级两个子球做准备,同时这也是当前球的半径
        max_dist = dists_with_center[max_dist_index]
        root = Ball(cur_center,max_dist,data,None,None)
        point1 = data[max_dist_index]

        dists_with_point1 = np.array([self.dist(point1[:-1],point) for point in data[:,:-1]])
        max_dist_index2 = np.argmax(dists_with_point1)
        point2 = data[max_dist_index2]            #取距离point1最远的点,至此,为寻找下一级的两个子球的准备工作搞定

        dists_with_point2 = np.array([self.dist(point2[:-1], point) for point in data[:, :-1]])
        assign_point1 = dists_with_point1 < dists_with_point2

        root.left = self.build_BallTree_core(data[assign_point1])
        root.right = self.build_BallTree_core(data[~assign_point1])
        return root    #是一个Ball

    def search_KNN(self,target,K):
        if self.root is None:
            raise Exception('KD-Tree Must Be Not empty.')
        if K > len(self.values):
            raise ValueError("K in KNN Must Be Greater Than Lenght of data")
        if len(target) !=len(self.root.center):
            raise ValueError("Target Must Has Same Dimension With Data")
        self.KNN_result = [(None,self.KNN_max_now_dist)]
        self.nums = 0
        self.search_KNN_core(self.root,target,K)
        return self.nums
        # print("calu_dist_nums:",self.nums)

    def insert(self,root_ball,target,K):
        for node in root_ball.points:
            self.nums += 1
            is_duplicate = [self.dist(node[:-1], item[0][:-1]) < 1e-4 and
                            abs(node[-1] - item[0][-1]) < 1e-4 for item in self.KNN_result if item[0] is not None]
            if np.array(is_duplicate, np.bool).any() and not allow_duplicate:
                continue
            distance = self.dist(target,node[:-1])
            if(len(self.KNN_result)<K):
                self.KNN_result.append((node,distance))
            elif distance < self.KNN_result[0][1]:
                self.KNN_result = self.KNN_result[1:] + [(node, distance)]
            self.KNN_result = sorted(self.KNN_result, key=lambda x: -x[1])


    #root是一个Ball
    def search_KNN_core(self,root_ball, target, K):
        if root_ball is None:
            return
        #在合格的超体空间(必须是最后一层的子空间)内查找更近的数据点
        if root_ball.left is None or root_ball.right is None:
            self.insert(root_ball, target, K)
        if abs(self.dist(root_ball.center,target)) <= root_ball.radius + self.KNN_result[0][1] : #or len(self.KNN_result) < K
            self.search_KNN_core(root_ball.left,target,K)
            self.search_KNN_core(root_ball.right,target,K)


if __name__ == '__main__':

    csv_path = "winequality-white.csv"
    data,lables,dim_label = load_data(csv_path)
    split_rate = 0.8
    K=5
    train_num = int(len(data)*split_rate)  # 80%训练数据
    print("train_num:",train_num)
    start1 = time.time()
    ball_tree = BallTree(data[:train_num], lables[:train_num])
    end1 = time.time()

    diff_all=0
    accuracy = 0
    search_all_time = 0
    calu_dist_nums = 0
    for index,target in enumerate(data[train_num:]):
        start2 = time.time()
        calu_dist_nums+=ball_tree.search_KNN(target, K)
        end2 = time.time()
        search_all_time += end2 - start2

        # for res in ball_tree.KNN_result:
        #     print("res:",res[0][:-1],res[0][-1],res[1])
        pred_label = Counter(node[0][-1] for node in ball_tree.KNN_result).most_common(1)[0][0]
        diff_all += abs(lables[index] - pred_label)
        if (lables[index] - pred_label) <= 0:
            accuracy += 1
        print("accuracy:", accuracy / (index + 1))
        print("Total:{},MSE:{:.3f}    {}--->{}".format(index + 1, (diff_all / (index + 1)), lables[index],
                                                   pred_label))


    print("BallTree构建时间:", end1 - start1)
    print("程序运行时间:", search_all_time/len(data[train_num:]))
    print("平均计算次数:", calu_dist_nums / len(data[train_num:]))
        #暴力KNN验证
        # KNN_res=[]
        # for index2,curData in enumerate(data[:train_num]):
        #     is_duplicate = [ball_tree.dist(curData,v[0])<1e-4 for v in KNN_res]
        #     if np.array(is_duplicate,np.bool).any() and not allow_duplicate:
        #         continue
        #     cur_dist = ball_tree.dist(curData,target)
        #     if len(KNN_res) < K:
        #        KNN_res.append((curData,lables[index2],cur_dist))
        #     elif cur_dist<KNN_res[0][2]:
        #         KNN_res = KNN_res[1:]+[(curData,lables[index2],cur_dist)]
        #     KNN_res=sorted(KNN_res, key=lambda x: -x[2])
        # pred_label2 = Counter(node[1] for node in KNN_res).most_common(1)[0][0]
        # for my_res in KNN_res:
        #     print("res:",my_res[0],my_res[1],my_res[2])
        # print("--------------{}--->{} vs {}------------------".format(lables[index],pred_label,pred_label2))

9、KNN模型的应用

KNN算法是一个非常优秀的数据挖掘模型,既可以解决离散型因变量的分类问题,也可以处理连续性因变量的预测问题。而且该算法对数据的分布特征没有任何要求。

Python中的sklearn模块提供了有关KNN算法实现分类和预测的功能,该功能存在于子模块nerghbors中,对于分类问题,需要调用KNeighborsClassifer类,而对于预测问题,则需要调用KNeighborRegressor类。

首先针对这两个类的语法和参数含义做详细描述:

from sklearn import neighbors

neighbors.KNeighborsClassifier(neighbors=5,weights='uniform',algorithm='auto',
                               leaf_size=30,p=2,metric='minkowskl',
                               metric_params=None,n_jobs=1)

neighbors.KNeighborsRegressor(neighbors=5,weights='uniform',algorithm='auto',
                               leaf_size=30,p=2,metric='minkowskl',
                               metric_params=None,n_jobs=1)

上述类中的参数的具体作用如下:

  • n_neighbor:用于指定近邻样本个数K,默认为5;
  • weights:用于指定近邻样本的投票权重,默认为’uniform’,表示所有近邻样本的投票权重一样;如果为’distance’,则表示投票权重与距离成反比,即近邻样本与未知类别的样本距离越远,权重越小,反之权重越大;
  • algorithm:用于指定近邻样本的搜寻算法,如果为’ball_tree’,则表示使用球树搜寻法找近邻样本;如果为’kd_tree’,则表示使用KD树搜寻法寻找近邻样本;如果为’brute’,则表示使用暴力搜寻法寻找近邻样本。默认为’auto’,表示KNN算法会根据数据特征自动选择最佳的搜寻算法;
  • leaf_size:用于指定球树或KD树叶子结点所包含的最小样本量,它用于控制树的生长条件,会影响树的查询速度,默认为30;
  • metric:用于指定距离的度量指标,默认为闵可夫斯基距离;
  • p:当参数metric为闵可夫斯基距离时,p=1,表示计算点之间的曼哈顿距离;p=2表示计算点之间的欧式距离,参数P的默认值为2;
  • metric_params:为metric参数所对应的距离指标添加关键字参数;
  • n_jobs:用于设置KNN算法并行计算所需的CPU数量,默认为1表示仅适用1个CPU运行算法,即不使用并行运算功能;

10、参考资料:

  1. kd树代码:https://baike.baidu.com/item/kd-tree/2302515?
  2. 球树搜寻法代码:https://github.com/qzq2514/KDTree_BallTree
  3. 《从零开始学Python —— 数据分析与挖掘》—— 刘顺祥;
  4. 《统计学习方法》—— 李航;
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值