K近邻(KNN)
k近邻(k-nearest neighbor)可以做分类也可以做回归任务。
k近邻的基本描述:选择预测点周围距离最近的k个点,通过这几个点的特征按照某种规则来预测预测点。
从上述描述中可以看出在k近邻中决定某个点的值有三个要素:
- k值的选择
- 距离的度量
- 分类决策的规则
分类决策的规则(学习的策略)
这里介绍一种分类决策的规则:多数表决制
多数表决制:在训练数据集中找到与该实例最近邻的k个实例,这k个实例多数属于某个类,就把该输入实例分为这个类。
分类决策的算法:
输入:训练数据集 T = {(x1,y1),(x2,y2),……,(xN,yN)}
其中,xi ∈ Rn 为实例的特征向量,yi ∈ Y = {c1,c2,……,cM} 为实例的类别,i = 1,2,……,N;预测实例为特征向量x;
输出:实例 x 所属的类 y
步骤:
- 根据给定的距离度量,找到 k 个与 x 最邻近的点,记做 x 的邻域 Nk(x)
所以多数表决制的公式为:
I 为指示函数,当 yi = cj 的时候 I 为1,否则 I 为0。
解释:分别查看M个类别,对于每个类别遍历邻域中的每个点,如果点的类等于当前类别cj则在cj位置加一(这样得到的就是邻域中类别为类别cj的点的总数)。所有类别作为一个数组,其中的每个元素值为一个类别的点的数量,每个元素下标表示类别的类型,argmax表示从数组中找到元素值最大的元素的下标返回。
例如上图,对于1类别而言执行了N次得到邻域中有10个点为1类别。对于每个类别逐步执行,得到上述的那样一个数组,然后找到元素值最大的下标为1,所以1类别就是预测实例的类别了。
证明为什么多数表决制可以作为学习策略:
假设预测实例的类别为cj,则它的邻域所属类别也是cj。那么误分类的概率就是:
(解释为邻域中所有不等于cj的数量除以总数量M)
由于是01分布也可以转换成一下公式(1减去分类正确的概率)
所以要是误分类的概率最小就要使得正确分类的概率最大,也就是多数表决机制等价于经验风险最小化。
K值的选择(学习的模型)
- k值较小,近似误差减小,估计误差增大,模型复杂,会产生过拟合
- k值较大,近似误差增大,估计误差减小,模型简单,会产生欠拟合
- 通过交叉验证的方法选择k值
近似误差:可以想象成train loss。当k值小时,影响预测实例的点就少,所以只有与输入实例相近似的点才会影响到预测。
估计误差:可以想象成test loss。当k值小时,当过来一个未知的实例时,点过少就会导致预测的时候误差过于大。
距离的度量(学习的算法)
特征空间中两个点的距离是两个实例点的相似程度的反映。
可以使用欧氏距离、Lp距离或曼哈顿距离。
Lp距离是更一般化的公式,当p=2的时候,这种距离就变成了欧式距离。
Lp距离的公式:
当p=2时,就变成了欧式距离:
当p=1时,就变成了曼哈顿距离:
当p=无穷时,距离的值就是各个坐标距离的最大值:
反映在图中就是:
在寻找最近点的时候可以遍历实例点,一个一个找,最终找到距离最近的那个点。但是这种方法浪费时空,所以使用 kd 树,kd树可以很好的解决这个问题,毕竟树搜索快一些。
kd树
构造kd树
给定一个数据样本集 S 含于 Rn 递归的执行以下步骤
- 如果 |S| = 1,记录 S 中唯一的节点作为叶节点,并且返回节点
- 如果 |S| > 1:
- 将 S 中的所有节点按照 r 轴进行排序,选出中位元素作为当前节点,并记录坐标轴 r。这样构造出来的是平衡kd树!
- 所有排在中位元素左边的元素作为左支,并且 r = (r + 1) mod n 调用递归函数。所有排在中位元素右边的元素作为右支,调用递归函数**(r从0开始,n为实例特征维度减1)**
因为我们想要轮流的切换坐标轴,所以这里的 r = (r + 1) mod n 是为了确保轴到达最后可以切换回0轴。
例如:
有这样一个二维数据集:
构造出来的kd树与其特征空间的划分如下图所示:
可以看出kd树的叶子节点是一个区域,非叶子节点是一个划分。
构造kd树代码如下:
class Node:
def __init__(self):
# coord为当前节点的坐标
self.coord = None
# left为左支节点
self.left = None
# right为右支节点
self.right = None
def setCoord(self, coord):
self.coord = coord
def setLeft(self, left):
self.left = left
def setRight(self, right):
self.right = right
class KdTree:
def __init__(self, coords, feature_dims):
self.nodes = self.putNode(coords)
self.feature_dims = feature_dims
self.root = self.build(self.nodes, 0)
def putNode(self, coords):
nodes = []
for coord in coords:
node = Node()
node.setCoord(coord)
nodes.append(node)
return nodes
def build(self, nodes, dim):
# 如果划分的节点中没有节点,则返回None
if len(nodes) == 0:
return None
# 如果划分的节点只剩下一个了,那么就返回这个节点
if len(nodes) == 1:
return nodes[0]
# 对节点进行排序
nodes = sorted(nodes, key = lambda x: x.coord[dim])
# 由于是平衡二叉树,所以需要取中间节点作为当前节点
medi = len(nodes) // 2
# 由于最后的left与right都可以落在这个对象上,所以不需要特意将该对象作为与原数组中对象相同
# 当前节点
c_node = nodes[medi]
# 构建左支
c_node.left = self.build(nodes[:medi], (dim+1)%self.feature_dims)
# 构建右支
c_node.right = self.build(nodes[medi+1:len(nodes)], (dim+1)%self.feature_dims)
print('当前节点:{} 左支节点:{} 右支节点:{}'.format(c_node.coord, None if c_node.left is None else c_node.left.coord, None if c_node.right is None else c_node.right.coord))
return c_node
演示是否构建成功:
coords = [[2, 3], [5, 4], [9, 6], [4, 7], [8, 1], [7, 2]]
x = KdTree(coords, 2)
结果:
当前节点:[5, 4] 左支节点:[2, 3] 右支节点:[4, 7]
当前节点:[9, 6] 左支节点:[8, 1] 右支节点:None
当前节点:[7, 2] 左支节点:[5, 4] 右支节点:[9, 6]
kd树搜索
利用kd数进行搜索可以省去大量的时间。
这里先介绍最邻近的搜索方法,再介绍k邻近的搜索方法。
-
设最近距离为无穷
-
从叶节点出发,递归的向下访问kd树。若目标节点x的当前维度(当前维度为当前节点记录的维度)坐标小于当前节点的当前维度,则移动到左支节点。否则移动到右支节点。判断此叶子节点与目标节点的距离是否小于当前最近距离,小于则以此叶子节点为最近点。(因为kd树类似于已经排序好的数组,找已排序好的数组中的最近位置类似于二分查找,所以找到的叶子节点肯定是距离目标节点最近的。但是仍有可能在其他分支拥有更近节点所以需要执行2步骤)
-
递归的向上回退,每个节点进行一下步骤:
- 如果当前节点保存的实例点比的当前最近点距离目标节点更近,则以该节点为当前最近点
- 检查当前节点的轴是否与目标节点和当前最近点形成的圆相交,如果相交那么就表示在当前节点的另一个分支中拥有比当前最近点更近的节点。那么就移动到另一个分支中进行最邻近搜索(具体的判断方法为当前节点当前维度的坐标减去目标节点当前维度的坐标是否小于最近距离)
- 如果不相交,向上回退
-
结束搜索,最后的当前最近点就是目标节点的最近邻点。
判断是否相交: A的某维度轴坐标大于S某维度轴坐标减最近距离。或A的某维度轴坐标小于S某维度轴坐标加最近距离
以下面例题为例子进行解释说明:
从上述例题可以得到如下kd树:
解题步骤:
- S一维度小于A一维度,找到左支B
- S零维度大于B零维度,找到右支D
- D为叶子节点,D的距离小于无穷将D作为当前最近点
- 向上回退到B,B到S的距离大于D到S的距离。并且B的零维度轴坐标小于S零维度轴坐标减最近距离,说明两者不相交,B的另一分支中不存在更近距离的点了。
- 向上回退到A,A到S的距离大于D到S的距离。但是A的一维度轴坐标小于S一维度轴坐标加最近距离,说明两者相交,所以A的另一分支可能存在更近距离的点。
- 以A的另一分支C为入口,寻找最近邻点。S零维度大于C零维度,找到右支E
- E为叶子节点并且距离小于当前最近距离
- 向上回退到C,C到S的距离大于E到S的距离。并且C的零维度轴坐标小于S零维度轴坐标减最近距离
- 向上回退到A
- 退出
k邻近与最邻近相似,就是将当前最近点换成一个数组。开始数组不满k个元素,无需判断直接将点添加进数组。当数组满时,有新元素添加进来则需要与数组中距离最大的那个元素判断,若小于则将距离最大的那个元素替换出去。最终得到的数组就是k个最近邻。然后用于KNN去判断预测实例的类别。
搜索kd树的k近邻代码如下:
import math
# tree为kd树,k为寻找的k个近邻,target为目标节点
def findKNode(tree, k, target):
kContainer = []
def search(node, k, target, kContainer):
# 如果没有节点则跳过去
if node is not None:
is_Left = None
# 如果容器中不足k个节点,则无脑将节点存到容器中
if len(kContainer) < k:
kContainer.append(node)
# 当前节点的切分维度,如果是叶子节点c_dim=None,如果是非叶子节点则会有一个dim
c_dim = node.dim
# 如果是非叶子节点则向下寻找子节点,并且记录进入的是左支还是右支
if c_dim is not None:
if target.coord[c_dim] >= node.coord[c_dim]:
search(node.right, k, target, kContainer)
is_Left = False
else:
search(node.left, k, target, kContainer)
is_Left = True
# 当前节点与目标节点之间的距离
c_dist = math.sqrt((node.coord[0] - target.coord[0]) ** 2 + (node.coord[1] - target.coord[1]) ** 2)
# 遍历的查找容器中与目标节点距离最大的节点的下标
max_index = 0
max_d = 0
max_node = None
# 寻找容器中与目标节点距离最大的节点
for i, con in enumerate(kContainer):
d = math.sqrt((con.coord[0] - target.coord[0]) ** 2 + (con.coord[1] - target.coord[1]) ** 2)
if d > max_d:
max_d = d
max_index = i
max_node = con
# 如果当前节点的距离小于容器中最大节点的距离则替换,并且该节点还不在容器中才进行替换
# 判断是否在容器中
inCon = False
if node in kContainer:
inCon = True
# 判断是否小于
if c_dist < max_d and not inCon:
kContainer[max_index] = node
# 如果不是叶子节点则判断当前节点的轴是否与容器中最大的节点与目标节点形成的圆是否相交
# 如果相交则表明轴的另一侧还有更近的点,dim=None的节点为叶子节点
if c_dim is not None:
r = math.sqrt(
(max_node.coord[0] - target.coord[0]) ** 2 + (max_node.coord[1] - target.coord[1]) ** 2)
# 小于r说明相交,如果相交则跳到另一个子节点中,因为已经访问过一个子节点了
if abs(target.coord[c_dim] - max_node.coord[c_dim]) < r:
if is_Left:
search(node.right, k, target, kContainer)
else:
search(node.left, k, target, kContainer)
search(tree.root, k, target, kContainer)
return kContainer
target = Node()
target.setCoord([6,2])
kNodes = findKNode(kdTree, 2, target)
for node in kNodes:
print(node.coord)
结果:
[7, 2]
[8, 2]