文章目录
KNN算法的原理
概念
K近邻算法(K Nearest Neighbour) 是常见的用于监督学习的算法。它的原理比较简单:
给定测试样本,基于某种距离找到训练集中与其最近的 K 个训练样本,然后基于这 K 个邻居的信息来预测。
KNN既可以用于分类问题,也可以用于回归问题:
分类 :投票法,选择K个邻居中出现次数最多的类别作为预测结果。
回归:可使用平均法,即将K个邻居的标记的平均值作为预测输出。也可以使用基于距离的加权平均,比如把距离的倒数作为权重进行加权运算。
距离
KNN用的是欧氏距离,设有两个
n
n
n 维的样本
x
=
(
x
1
,
x
2
,
.
.
,
x
n
)
,
y
=
(
y
1
,
y
2
,
.
.
.
,
y
n
)
\bm x=(x_1,x_2, .., x_n), \bm y=(y_1, y_2, ..., y_n)
x=(x1,x2,..,xn),y=(y1,y2,...,yn), 它们之间的欧式距离表示为:
d
=
∑
i
=
1
n
(
x
i
−
y
i
)
2
d=\sqrt{\sum_{i=1}^{n}{(x_i-y_i)^2}}
d=i=1∑n(xi−yi)2
K值的选择
- 如果选择较小的K值,就相当于用较小的领域中的训练实例进行预测,“学习”近似误差会减小,只有与输入实例较近或相似的训练实例才会对预测结果起作用,与此同时带来的问题是“学习”的估计误差会增大,换句话说,K值的减小就意味着整体模型变得复杂,容易发生过拟合;
- 如果选择较大的K值,就相当于用较大领域中的训练实例进行预测,其优点是可以减少学习的估计误差,但缺点是学习的近似误差会增大。这时候,与输入实例较远(不相似的)训练实例也会对预测器作用,使预测发生错误,且K值的增大就意味着整体的模型变得简单。
- K=N,则完全不足取,因为此时无论输入实例是什么,都只是简单的预测它属于在训练实例中最多的累,模型过于简单,忽略了训练实例中大量有用信息。
在实际应用中,K值一般取一个比较小的数值,例如采用交叉验证法(简单来说,就是一部分样本做训练集,一部分做测试集)设定不同的K来做多次测试,从而来选择最优的K值。
如何求最近的K个结点的距离
线性扫描
通常可以计算所有训练样本到测试样本的距离,把训练样本按到测试样本的距离从宏观小到大排序,选择前 K K K 个即可。
大顶堆优化
但是这样的话排序的时间复杂度比较大,可以用一个 大顶堆 来优化,先压入 K K K 个元素进入堆中,接下来每计算一个新的训练样本到测试样本的距离,就把该距离和 堆顶元素 相比较,若小于 堆顶元素 就把 堆顶元素 对应的样本弹出,把新的这个训练样本压入堆。这样就可以减少排序的复杂度。
kd tree 优化
即使使用 大顶堆 来优化,还是需要计算每个训练样本和测试样本的距离,当样本数量巨大时,计算次数将会十分巨大,时间复杂度也很大。kd tree 就是一种对训练样本进行重新存储进而减少距离计算次数的二叉树。通常对于训练样本数为 m, 维度为 d 的训练集, kd tree 适用于 m ≫ 2 d m \gg 2^d m≫2d 的情况。
kd tree的原理
kd树是一种对k维空间中的样本点进行存储以便对其进行快速检索的二叉树。它表示的是对k维空间的一种划分(partition)。构造kd树的相当于不断地用垂直于坐标轴的超平面将k维空间进行切割划分,构成一系列k维超矩形区域,kd树的每一个结点对应于一个k维超矩形区域。
kd tree的建立
常规的kd tree的构建过程为:循环依序取数据点的各维度来作为切分维度,取数据点在该维度的中值作为切分超平面,将中值左侧的数据点挂在其左子树,将中值右侧的数据点挂在其右子树。递归处理其子树,直至所有数据点挂载完毕。
通常轮次选择坐标轴对空间进行切分,选择训练样本在当前坐标轴上的中位数(median)作为切分点,这样得到的树是平衡的,但是,平衡的kd树未必是效率最优的kd树。
假设在2维空间中有这么一些训练样本:
D = { ( 2 , 3 ) , ( 5 , 4 ) , ( 9 , 6 ) , ( 4 , 7 ) , ( 8 , 1 ) , ( 7 , 2 ) } D=\{(2, 3), (5, 4), (9, 6), (4, 7), (8, 1), (7, 2)\} D={(2,3),(5,4),(9,6),(4,7),(8,1),(7,2)}
以下两图来自于维基百科 k-d tree。我们的样本数据有两个维度,交替使用这两个维度来划分空间。先基于第一个维度,也就是横坐标 x , 所有样本的横坐标集合是 { 2 , 5 , 9 , 4 , 8 , 7 } \{2,5,9,4,8,7\} {2,5,9,4,8,7},显然 它们的中位数是7,以 ( 7 , 2 ) (7, 2) (7,2) 作为根结点,沿垂直当前维度也就是 x x x 轴方向过 ( 7 , 2 ) (7, 2) (7,2) 作一条垂直于当前维度的超平面,由于是二维空间,超平面退化为一条直线。超平面以左的点,也就是数据点当前维度的元素小于7的作为 ( 7 , 2 ) (7, 2) (7,2) 的左子树,超平面以右的点作为它的右子树。这是kd树的第一层。
然后基于第二个维度,也就是 y 轴,分布对 ( 7 , 2 ) (7, 2) (7,2) 的左子树 { ( 2 , 3 ) , ( 5 , 4 ) , ( 4 , 7 ) } \{(2, 3), (5, 4), (4, 7)\} {(2,3),(5,4),(4,7)} 和右子树 { ( 9 , 6 ) , ( 8 , 1 ) } \{(9, 6), (8, 1)\} {(9,6),(8,1)} 做类似的划分。对于左子树,各样本点纵坐标的中位数是 4,因此 ( 5 , 4 ) (5, 4) (5,4) 是中间点,作为 ( 7 , 2 ) (7, 2) (7,2) 的左子树的根结点。同理, ( 9 , 6 ) (9, 6) (9,6) 是右子树的根结点。过 ( 5 , 4 ) (5, 4) (5,4) 和 ( 9 , 6 ) (9, 6) (9,6) 分别作垂直于当前维度y轴的直线,直到与其它超平面相交为止。
最后,对剩下的三个叶子结点在 x 轴维度上做超平面。至此完成对kd树的建立。
kd tree的最近邻搜索
假设我们现在有一个k维空间中的目标点 p 和一棵 kd树,可以按照如下方法寻找 kd树 中距离 p 最近的点。
- 在kd树中找到包含目标的叶子结点。方法是,从根结点出发,若 p 的当前维坐标小于根结点的对应坐标,则去左结点查找,否则去右结点,直到抵达叶子结点为止。此时说明 p 位于该叶子结点的超空间内。以这个叶子结点为当前最近点。
- 递归向上回退访问,在每个结点进行以下操作之一:
a. 如果当前结点距离 p 更近,则更新为当前最近点。然后接着向上回退。
b. 否则, 当前最近点一定位于当前结点的某个子结点所在超空间,检查当前结点的另一个子结点所在空间是否有距离 p 更近的结点。具体操作就是,以目标点p到当前最近点的距离为半径作一个超球面,看超球面是否与经过当前结点的超平面相交,若相交,说明该区域可能存在距离更近的结点,则先要递归访问当前结点的另一个子结点之后才能继续向上回退。若不相交,则接着向上回退。 - 当回退到根结点时,搜索结束,最后的当前最近点即为 p 的最近点。
伪代码如下:
struct Node{
int dim; // 当前维度
vector<int> vec; // 样本点数据
Node* left = nullptr; // 左孩子结点
Node* right = nullptr; // 右孩子结点
Node* father = nullptr; // 父亲结点
}
void search(Node* root, Node* aim, Node* &bestNeighbor, double& minDistance){
/*
root: 当前访问的树的根结点
aim: 目标结点
bestNeighbor: 离目标结点最近的结点
minDistance: bestNeighbor离目标结点的距离
*/
Node* now = root; // now表示当前结点
// 找到 aim所在超矩形区域的叶子结点
while(now->left != nullptr || now->right != nullptr){
if(root->vec[root->dim] < aim->vec[root->dim]){
now = root->right;
}else{
now = root->left;
}
}
minDistance = calcDistance(now, aim);
bestNeighbor = now;
// 从叶子结点开始向上回退
while(now != root){
now = now->father;
double tempDistance = calcDistance(now, aim);
if(tempDistance > minDistance){
// 更新当前最近点
bestNeighbor = now;
}else{
double radius = calcDistance(bestNeighbor, aim);
double superSurfaceToAim = calcDistToSuperSurface(now, aim);
// 超球面和过当前点的超平面相交
if(radius > superSurfaceToAim){
if(now->vec[now->dim] > aim->vec[now->dim]){
// 如果本来在左子树, 那么递归访问右子树
search(now->right, aim, bestNeighbor,minDistance);
}else{
// 如果本来在右子树, 那么递归访问左子树
search(now->left, aim, bestNeighbor,minDistance);
}
}
}
} // end while
// 判断 root是不是最近的结点
double distance = calcDistance(now, aim);
if(distance < minDistance){
minDistance = distance;
bestNeighbor = root;
}
}
我做了个动态示意图, 下图中
S
S
S 是我们要找的点,
A
A
A 是kd树的根结点,黄色圆圈表示该结点是当前结点, 橘黄色的字母表示该结点是当前最近点, 蓝色的边是超平面,红色的圈是超球面。
寻找最近邻的过程是这样的,先找到以A 为根结点的树中所属超矩形空间包含S的叶子结点 D。从 D 开始回退,回退到 B 时发现,此时当前最近点是 D, 超球面与当前点 B 的父亲结点 A 所在超平面相交。于是在以 A 的另一个孩子结点 C 为根结点的子kd树中递归寻找最近邻点。在 C 为根结点的子kd树中,先找到 叶子结点 E, 然后开始回退访问 C, 此时当前最近点仍然是 E, 这段子递归结束。最后回退 A, 此时当前最近点仍然是 E。最后我们寻找的距离 S 最近的点就是 E。
KNN算法的优缺点
优点
- 简单易用
- 没有显式的训练过程,在训练过程中仅仅是把训练样本保存起来,训练时间开销为0,是懒惰学习(lazy learning) 的著名代表 。
- 预测效果好
- 对异常值不敏感
缺点
- 对内存要求较高,因为该算法存储了所有训练数据
- 预测阶段可能很慢
- 对不相关的功能和数据规模敏感
Reference
《机器学习》,周志华著
《统计机器学习》,李航著
CSDN-zhiyong_will: 数据结构和算法——kd树
磊磊落落的博客《k-d tree算法原理及实现》
维基百科 k-d tree