KNN算法,中文名称为最近邻算法,k-近邻法是一种基本分类与回归算法。和其它有监督算法不同,KNN算法是一种“惰性”学习算法,即不会预先生成一个分类或预测模型,用于新样本的预测,而是将模型的构建与未知数据的预测同时进行。
KNN算法和决策树类似,即可以针对离散因变量做分类,又可以对连续因变量做预测,其核心思想就是比较已知y值的样本和未知y值样本的相似度,然后寻找最相似的k个样本用作未知样本的预测。
k值的选择、距离度量及分类决策规则是2k近邻法的三个基本要素,k-近邻法于1968年由Cover和Hart提出。
K最近邻算法,顾名思义就是搜寻最近的k个已知类别样本用于未知类别样本的预测。
KNN算法的具体步骤可以描述为:
如果存在两个已知类别样本到未知类别样本的距离一样,但是两个已知类别样本的类别是矛盾的,则根据已知类别出现的先后顺序决定选择哪个。
KNN算法也能用于回归问题,假设离测试样本最近的k个训练样本的标签值为 y i y_i yi,则对样本的回归预测输出值为 y ^ = ( ∑ i = 1 k y i ) / k \hat{y}=(\sum_{i=1}^ky_i)/k y^=(i=1∑kyi)/k即所有邻居的标签均值,在这里最近的k个邻居的贡献被认为是相等的。同样也可以采用带权重的方案。带样本权重的回归预测函数为 y ^ = ( ∑ i = 1 k w i y i ) / k \hat{y}=(\sum_{i=1}^kw_iy_i)/k y^=(i=1∑kwiyi)/k其中, w i w_i wi为第 i i i个样本的权重,权重值可以人工设定,或者用其它方法来确定。例如,设置权重为与距离成反比。
通过上面步骤,能够理解为什么该算法被称为“惰性”算法,如果该算法仅仅接受已知类别的样本点,它是不会进行模型运算的,只有将未知类别样本加入到已知类别样本中,才会执行搜寻工作,并将最终的分类结果返回。
根据经验发现,不同的K值对模型的预测准确性会有较大的影响,如果K值过于偏小,可能会导致模型的过拟合;如果K值过大,有可能会使模型进入欠拟合状态。
这个举个例子说明一下:
为了获得最佳的K值,可以考虑两种解决方案:
KNN分类算法的思想是计算位置分类的样本点与已知分类的样本点之间的距离,然后将位置分类最近的K个已知分类样本用作投票。所以该算法的一个重要步骤是计算他们之间的相似度。下面简单介绍一下欧氏距离,曼哈顿距离,然后拓展两种相似度的度量指标,一个是余弦相似度,另一个是杰卡德相似系数。
KNN算法的实现依赖于样本之间的距离值,因此,需要定义距离的计算公式。两个向量之间的距离为 d ( x i , x j ) d(x_i,x_j) d(xi,xj),这是一个将两个维数相同的向量映射为一个实数的函数。距离函数必须满足以下条件:
满足上面4个条件的函数都就可以用作距离定义。
该距离度量的是两点之间的直线距离,如果二维平面中存在两点 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=(x1−x2)2+(y1−y2)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=(y1−x1)2+(y2−x2)2+...+(yn−xn)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 ∣ + ∣ y 1 − y 2 ∣ d_{A,B}=|x_1-x_2|+|y_1-y_2| dA,B=∣x1−x2∣+∣y1−y2∣其具体几何意义可以看一下下面的图:
同样地,如果将点扩展到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=∣y1−x1∣+∣y2−x2∣+...+∣yn−xn∣
该相似度是计算两点所构成向量夹角的余弦值,夹角越小,则余弦值越接近于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+y22x1x2+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θ=∣∣A∣∣∣∣B∣∣A.B其中, . . .代表两个向量之间的内积,符号 ∣ ∣ ∣ ∣ ||\space|| ∣∣ ∣∣代表向量的模,即L2正则化。
该相似系数与余弦相似度经常被用于推荐算法,计算用户之间的相似度。例如,用户A购买了10件不同的商品,B用户购买了15件不同的商品,则两者之间的相似系数可以表示为: J ( A , B ) = ∣ A ⋂ B ∣ ∣ A ⋃ B ∣ J(A,B)=\frac{|A\bigcap B|}{|A\bigcup B|} J(A,B)=∣A⋃B∣∣A⋂B∣其中, ∣ A ⋂ B ∣ |A\bigcap B| ∣A⋂B∣表示两个用户所购买相同商品的数量, ∣ A ⋃ B ∣ |A\bigcup B| ∣A⋃B∣代表两个用户购买的所有商品的数量。杰卡德相似稀疏越大,代表样本之间越接近。
使用距离方法来度量样本间的相似度,必须注意两点,一个是所有变量的数值化,如果某些变量为离散型的字符串,它们是无法计算距离的,需要对其做数值化处理,如构造哑变量或强制数值编码(例如将受教育水平的高中、大学、硕士及以上三种离散值重编码为0,1,2);另一个是防止数值变量的量纲影响,在实际项目的数据中,不同变量的数值范围可能是不一样的,这样就会使计算的距离值收到影响,所以必须采用数据的标准化方法对其归一化,使得所有变量的数值具有可比性。
在确定好某种距离计算公式后,KNN算法就开始搜寻最近的K个已知类别样本点。实际上,该算法在搜寻过程中是非常耗内存的,因为它需要不停地比较每一个未知样本与已知样本之间的距离。因此在暴力搜寻法的基础上,提出一些效率提升的算法,如KD树搜寻法和球树搜寻法,使用不同的搜寻方法往往会提升模型的执行效率。
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))=1−P(Y=f(X))对给定的实例 x ∈ X x\in X x∈X,其最近邻的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) k1xi∈Nk(x)∑I(yi=cj)=1−k1xi∈Nk(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) ∑xi∈Nk(x)I(yi=cj)最大,所以多数表决规则等价于经验风险最小化。
对未知类别属性的数据集中的每个点依次执行以下操作:
方法(一)
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))
代码中有三部分值得学习:
方法(二)
# 代码来源: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) # 计算准确度
搜寻的实际就是计算比比较未知样本和已知样本之间的距离,最简单粗暴的方法是全表扫描,该方法被称为暴力搜寻法。
例如,针对某个未知类别的测试样本,需要计算它与所有已知类别的样本点之间的距离,然后从中挑选出最近的k个样本,再基于这k个样本进行投票,将票数最多的类别用作未知样本的预测。该方法简单而直接,但是该方法只能适合小样本的数据集,一旦数据集的变量个数和观测个数较大时,KNN算法的执行效率就会非常低下。其运算过程就相当于使用了两层for循环,不仅要迭代每一个未知类别的样本,还需要迭代所有已知类别的样本。
为了避免全表扫描,提出了KD搜索树和球形搜寻法。每个算法的出现必定有其意义,只有知道其出现的意义才能更好地理解。下面我们介绍一下这两种提高KNN执行效率的搜寻方法:
KD树的英文名称为K-Dimension Tree,是一种二分支的树结构,这里的K表示训练集中包含的变量个数,而非KNN模型中的K个近邻样本。其最大的搜寻特点是先利用所有已知类别的样本点构造一个树模型,然后将未知类别的测试集应用在树模型上,实现最终的预测功能。先建树后预测的模式,能够避免全表扫描,提高KNN模型的运行速度。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树;
开始:构造根结点,根结点对应于包含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)大于切分点的子区域;
将落在切分超平面上的实例点保存在根结点。
重复:对深度为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)大于切分点的子区域。
将落在切分超平面上的实例点保存在该结点;
直到两个子区域没有实例存在时停止,从而形成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
当一个未知类别的样本进入到KD树后,就会自顶向下地流淌到对应的叶子结点,并开始反向计算最近邻的样本。有关KD树的搜寻步骤可以描述为:
# 代码来源:百度百科(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的距离与划分轴有交叉,则对其左/右子树进行遍历。
尽管kd树搜寻法相比于暴力搜寻法要快得多,但是该方法在搜寻分布不均匀的数据集时,效率会下降很多,因为根据结点切分的超矩形体都含有“角”。如果构成的球体与“角”相交,必然会使搜寻路径扩展到“角”相关的超矩形体内,从而增加搜寻的时间。
球树搜寻法之所以能够解决kd树的缺陷,是因为球树将kd树中的超矩形体换成了超球体,没有了角,就不容易产生模棱两可的区域。对比球树的构造和搜寻过程,会发现与kd树的思想非常相似,所不同的是,球体的最优搜寻路径复杂度提高了,但是可以避免很多无谓样本点的搜寻。
不同的超球体囊括了对应的样本点,超球体相当于树中的结点,所以构造球体的过程就是构造树的过程,关键点在于球心的寻找和半径的计算。
球树的构造如下:
从上面的步骤可知,球树的根结点就是囊括所有训练数据集的最小超球体,根结点的两个子结点就是由步骤2中两个数据块构成的最小超球体。以此类推,可以不停地将数据划分到对应的最小超球体中,最终形成一颗球树;
球树在搜寻最近邻样本时与KD树非常相似,下面详细介绍球树在搜寻过程中的具体步骤:
球树搜寻法的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 = 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))
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)
上述类中的参数的具体作用如下: