阅读本文需要的背景知识点:一丢丢编程知识
一、引言
前面一节我们学习了机器学习算法系列(二十)-梯度提升决策树算法(Gradient Boosted Decision Trees / GBDT),是一种集成学习的算法。这一节我们来学习一个相对简单直观的算法——k近邻算法1(k-Nearest Neighbor / kNN Algorithm)。
二、模型介绍
k近邻算法的思想非常的直观:给定一个新样本点时,只需要在训练集中找到距离最近的 k 个样本点,按照一定的决策规则得到新样本点的预测结果。
如图2-1 所示的分类问题,其中蓝色圆点表示分类为 +1 的样本点,红色叉表示分类为 -1 的样本点,绿色问号表示一个新样本点,当 k = 5 时,即图中绿色虚线圆,包含距离最近的 5 个样本点,其中 3 个为 -1,2 个为 +1,由于 -1 的分类数量多于 +1 的分类数量,则该新样本点的分类被预测为 -1。
图2-1从上面的示例中,我们可以看到整个预测过程由三个基本要点组成,分别是:距离、k 值、决策规则。
距离
既然是近邻,那么如何衡量这个“近”呢?答案自然是通过距离的大小来决定远近,距离的定义中常见的有欧式距离2(Euclidean distance),也被称为 2-范数距离:
对于更一般的情况,有明可夫斯基距离3(Minkowski distance),可以看作是欧式距离的一种推广:
一般算法中默认都是使用欧式距离来作为远近的判断,在 scikit-learn 中可以使用参数 p 来控制距离范数。
k 值
k 值的选取对最后的预测结果有着决定性的影响,当 k 等于 1 时,最后的预测结果只会等同于最近邻居的样本分类,当 k 等于训练集的样本数时,最后的预测结果只会返回训练集中分类占比最大的分类。
由此可以看出,当 k 越小时,意味着模型越复杂,越容易产生过拟合的情况;当 k 越大时,意味着模型越简单,越容易产生欠拟合的情况。一般 k 值会取一个相对较小的值,并通过交叉验证的方式得到一个最优的 k 值。
决策规则
决策规则一般是采用“少数服从多数”的思想。对于分类问题,这 k 个最近的样本中分类最多的类型即为该新样本点的类型;对于回归问题,将这 k 个最近的样本对应的标签值进行平均即为该新样本点的预测值。
三、算法步骤
k 近邻算法
分类
- 保存训练集
- 查询最近的 k 个样本点
- 返回这 k 个样本点分类最多的类型
回归
- 保存训练集
- 查询最近的 k 个样本点
- 返回这 k 个样本点的标签平均值
可以看到算法中最核心的方法就是查询最近的 k 个样本点,其中一个最简单的方法就是线型扫描,即遍历整个训练集进行搜索,但当训练集中的样本数过大时,该方式需要对每一个训练样本计算距离,往往过于耗时,这时可以考虑使用一个特殊的结构来存储一开始的训练集,在搜索阶段减少对距离的计算次数,以达到快速检索的目的。
k-维树4(k-d tree)
构建 k-d 树
k-d 树是一颗二叉树,按照特征的维度依次进行划分,形成一个超平面,分成两个区域,递归的进行划分,直到无法划分或者区域中包含了指定大小的特征矩阵为止。
图3-1 如图3-1 所示,根据一组二维的训练集,构建出一颗 k-d 树,步骤如下:
- 首先沿着横轴的方向找到该维度的特征值的中位数,垂直于横轴做切分,即图中蓝色竖线,将数据分成了左右两个子区域;
- 再分别沿着竖轴方向找到左右两个区域在该维度下的特征值的中位数,垂直于竖轴做切分,即图中两条绿色横线;
- 继续沿着横轴的方向进行切分,得到图中三条紫色的竖线;
- 这时所有子区域都无法再切分,停止算法;
得到如图3-2所示的 k-d 树
图3-2查询 k 近邻
给定一个目标点,查询该点的 k 近邻。只需要搜索包含该目标点的区域和以目标点为圆心,与当前最近点的距离作为半径的圆与超平面相交的区域,相对于线性扫描减少了计算距离的次数。
图3-3 如图3-3 所示,给定一个目标点(图中黑色叉),根据构建出的 k-d 树,进行查询最近邻,步骤如下:
- 首先从根结点依次向下找到包含目标点区域的叶子结点,即图中左下角的区域;
- 以当前区域中的点作为当前最近点;
- 递归的向上查询;
- 检查每一个结点与当前目标点的距离是否小于当前最近点的距离,如果小于则更新当前最近点;
- 检查当前结点的父结点的另一个子结点的区域是否与以目标点为圆心当前最近点之间的距离为半径的圆相交。如果相交,说明可能存在更近点,移动到另一个子结点进行递归搜索;
- 当搜索到根结点后,结束搜索,当前最近点即为目标点的最近点;
当要搜索 k 个最近点时,只需要按顺序记录即可,算法步骤相同,具体实现可以参考下面的代码实现。
当维度较小时, k-d 树确实能减少查询时计算距离的次数,但是由于树的切分规则是根据维度来做的,当维度过大时,其查询的性能与线性扫描差不多,所以就出现了 Ball 树来解决维度过大时查询的性能问题。
Ball 树5(Ball tree)
构建 Ball 树
Ball 树也是一颗二叉树,只是切分的方法不再是按维度来分割,而是每次使用超球面进行切分。
图3-4 如图3-4 所示,根据一组二维的训练集,构建出一颗 Ball 树,步骤如下:
- 找到当前样本集中宽度最大的维度,并找到当前维度下最小与最大点的中点作为超球面的球心,即图中的蓝色空心点;
- 以球心到样本集中最远的距离作为半径,得到超球面,即图中的蓝色虚线;
- 按照样本点到球心的距离将当前样本集分成两个集合;
- 重复上述 1 - 3 步,直到无法切分停止算法;
查询 k 近邻
给定一个目标点,查询该点的 k 近邻。同 k-d 树一样,搜索包含该目标点的区域和以目标点为圆心,与当前最近点的距离作为半径的圆与超球面相交的区域,由于是球型与球型相交,相对于 k-d 树进一步减少了计算距离的次数。
图3-5 如图3-5 所示,给定一个目标点(图中黑色叉),根据构建出的 Ball 树,进行查询最近邻,步骤如下:
- 首先从根结点依次向下找到其中一个叶子结点,作为当前最近点,即图中左下的第二个点;
- 依次搜索每一个结点,当当前结点的形成的球与目标点形成的球相交时,说明可能存在比当前最近点更近的点,如图中第一个出现的橘色圆和第二个出现的绿色圆;
- 当两个球不相交时,则跳过该结点及其子结点,如图中第二、三个出现的橘色圆;
- 当全部结点都处理过后,结束搜索,当前最近点即为目标点的最近点;
与 k-d 树一样,当要搜索 k 个最近点时,只需要按顺序记录即可,算法步骤相同,具体实现可以参考下面的代码实现。
四、代码实现
使用 Python 实现 k-d 树
class kdnode:
"""
k-d 树结点
"""
def __init__(self, feature, value, index, left = None, right = None):
# 结点对应特征的维度下标
self.feature = feature
# 结点对应训练集的特征值;当结点为叶子结点时,为特征向量
self.value = value
# 结点对应训练集的下标;当结点为叶子结点时,为下标向量
self.index = index
# 左子树
self.left = left
# 右子树
self.right = right
class kdtree:
"""
k-d 树算法实现
参数
----------
X : 特征矩阵
leaf_size : 叶子节点包含的最大特征矩阵数量,默认为 10
"""
def __init__(self, X, leaf_size = 10):
def build_node(X, X_indexes, depth, leaf_size):
"""
构建结点
参数
----------
X : 特征矩阵
X_indexes : 特征矩阵下标
depth : 深度
leaf_size : 叶子节点包含的最大特征矩阵数量
"""
# 当前特征的维度下标
feature = np.mod(depth, X.shape[1])
# 当前特征矩阵的大小小于等于指定的叶子结点包含的特征矩阵数量时构建叶子结点并返回
if X.shape[0] <= leaf_size:
return kdnode(feature, X, X_indexes)
# 当前特征维度下的特征向量
X_feature = X[:, feature]
# 按照当前特征维度排序后的下标向量
X_sort_indexes = np.argsort(X_feature)
# 按照当前特征维度排序后的特征矩阵
X_sort = X[X_sort_indexes]
# 中间下标
median = X.shape[0] // 2
# 左边的特征矩阵
X_left = X_sort[:median]
# 左边的特征矩阵对应排序后的下标
X_left_index = X_indexes[X_sort_indexes[:median]]
# 递归的构建左子树
left = build_node(X_left, X_left_index, depth + 1, leaf_size)
# 右边的特征矩阵
X_right = X_sort[median + 1:]
# 右边的特征矩阵对应排序后的下标
X_right_index = X_indexes[X_sort_indexes[median + 1:]]
# 递归的构建右子树
right = build_node(X_right, X_right_index, depth + 1, leaf_size)
# 构建当前结点并返回
return kdnode(feature, X_sort[median], X_indexes[X_sort_indexes[median]], left, right)
# 根结点
self.root = build_node(X, np.array(range(X.shape[0])), 0, leaf_size)
def query(self, X, k = 1, p = 2):
"""
查询距离最近 k 个特征向量
参数
----------
X : 特征矩阵
k : 最近邻的数量,默认为 1
p : 距离范数,默认为 2,即欧式距离
"""
# 最近邻对应的下标向量
nearests = -np.ones((len(X), k), dtype = np.int8)
# 最近邻对应的距离向量
distances = -np.ones((len(X), k))
return self.search(X, self.root, nearests, distances, p)
def search(self, X, node, nearests, distances, p = 2):
"""
搜索距离最近 k 个特征向量
"""
# 当前结点不是叶子结点时
if node.left is not None or node.right is not None:
# 当前特征下的特征值与切分值之差
axis = X[:, node.feature] - node.value[node.feature]
# 切分点左边
axis_left = axis < 0
if (axis_left).any():
# 递归的搜索左子树
nearests[axis_left, :], distances[axis_left, :] = self.search(X[axis_left, :], node.left, nearests[axis_left, :], distances[axis_left, :], p)
# 切分点右边
axis_right = ~axis_left
if (axis_right).any():
# 递归的搜索右子树
nearests[axis_right, :], distances[axis_right, :] = self.search(X[axis_right, :], node.right, nearests[axis_right, :], distances[axis_right, :], p)
# 计算距离
dist = distance(X, node.value, p)
# 是否所有特征点都处理过
all_cond = np.zeros((X.shape[0],), dtype=np.bool)
# 依次遍历 k 次
for i in range(nearests.shape[1]):
# 当前记录的距离为-1或者新的距离小于当前记录的距离
cond = (~all_cond) & ((distances[:, i] < 0) | (dist - distances[:, i] < 0))
# 没有满足条件的特征点就跳过
if (~cond).all():
continue
# 插入最新的下标值
ns = np.insert(nearests[cond, :], i, node.index, axis=1)
nearests[cond, :] = ns[:,:-1]
# 插入最新的距离
ds = np.insert(distances[cond, :], i, dist[cond], axis=1)
distances[cond, :] = ds[:,:-1]
# 更新判断条件
all_cond = all_cond | cond
# 所有特征点都处理过则跳出
if all_cond.all():
break
# 距离记录中最大的距离大于切分轴(即另一边的子树可能包含更近的邻居)
over = np.max(distances, axis=1) - np.abs(axis) > 0
if over.any():
# 递归的搜索右子树
over_left = over & axis_left
if (over_left).any():
nearests[over_left, :], distances[over_left, :] = self.search(X[over_left, :], node.right, nearests[over_left, :], distances[over_left, :], p)
# 递归的搜索左子树
over_right = over & axis_right
if (over_right).any():
nearests[over_right, :], distances[over_right, :] = self.search(X[over_right, :], node.left, nearests[over_right, :], distances[over_right, :], p)
else:
# 依次遍历当前叶子结点包含的特征向量
for i in range(len(node.value)):
# 更新下标与距离记录的方式同上
dist = distance(X, node.value[i], p)
all_cond = np.zeros((X.shape[0],), dtype=np.bool)
for j in range(nearests.shape[1]):
cond = (~all_cond) & ((distances[:, j] < 0) | (dist - distances[:, j] < 0))
if (~cond).all():
continue
ns = np.insert(nearests[cond, :], j, node.index[i], axis=1)
nearests[cond, :] = ns[:,:-1]
ds = np.insert(distances[cond, :], j, dist[cond], axis=1)
distances[cond, :] = ds[:,:-1]
all_cond = all_cond | cond
if all_cond.all():
break
return nearests, distances
复制代码
使用 Python 实现 Ball 树
class ballnode:
"""
ball 树结点
"""
def __init__(self, value, index, radius, left = None, right = None):
# 结点对应训练集的特征值;当结点为叶子结点时,为特征向量
self.value = value
# 结点对应训练集的下标;当结点为叶子结点时,为下标向量
self.index = index
# 超球体的半径
self.radius = radius
# 左子树
self.left = left
# 右子树
self.right = right
class balltree:
"""
ball 树算法实现
参数
----------
X : 特征矩阵
leaf_size : 叶子节点包含的最大特征矩阵数量,默认为 10
p : 距离范数,默认为 2,即欧式距离
"""
def __init__(self, X, leaf_size = 10, p = 2):
def build_node(X, X_indexes, leaf_size):
"""
构建结点
参数
----------
X : 特征矩阵
X_indexes : 特征矩阵下标
leaf_size : 叶子节点包含的最大特征矩阵数量
"""
# 当前特征矩阵的大小小于等于指定的叶子结点包含的特征矩阵数量时构建叶子结点并返回
if X.shape[0] <= leaf_size:
return ballnode(X, X_indexes, None)
# 距离最宽的维度(标准差越大,代表该维度下样本点之间差距有大)
feature = np.argmax(np.std(X, axis=0))
# 该维度下最大的样本点
X_feature_max = X[np.argmin(X[:, feature])]
# 该维度下最小的样本点
X_feature_min = X[np.argmax(X[:, feature])]
# 中心点
X_feature_median = (X_feature_max + X_feature_min) / 2
# 每个样本点与中心点之间的最大距离
radius = np.max(distance(X, X_feature_median, p))
# 将样本点分成两类
left_index = (distance(X, X_feature_max, p) - distance(X, X_feature_min, p)) < 0
if left_index.any():
# 递归的构建左子树
left = build_node(X[left_index, :], X_indexes[left_index], leaf_size)
right_index = ~left_index
if right_index.any():
# 递归的构建右子树
right = build_node(X[right_index, :], X_indexes[right_index], leaf_size)
# 构建当前结点并返回
return ballnode(X_feature_median, None, radius, left, right)
# 根结点
self.root = build_node(X, np.array(range(X.shape[0])), leaf_size)
def query(self, X, k = 1, p = 2):
"""
查询距离最近 k 个特征向量
参数
----------
X : 特征矩阵
k : 最近邻的数量,默认为 1
p : 距离范数,默认为 2,即欧式距离
"""
# 最近邻对应的下标向量
nearests = -np.ones((len(X), k), dtype = np.int8)
# 最近邻对应的距离向量
distances = -np.ones((len(X), k))
return self.search(X, self.root, nearests, distances, p)
def search(self, X, node, nearests, distances, p = 2):
"""
搜索距离最近 k 个特征向量
"""
# 当前结点不是叶子结点时
if node.left is not None or node.right is not None:
# 最大的距离
max_distance = np.max(distances, axis=1)
# 样本点与当前结点对应的超球面最近的距离大于当前的最大距离时,其子结点不可能存在跟近的距离,直接跳过
over = ((distance(X, node.value, p) - node.radius - max_distance) >= 0) & (distances != -1).all()
if over.all():
return nearests, distances
unover = ~over
# 递归搜索左子数
nearests[unover, :], distances[unover, :] = self.search(X[unover, :], node.left, nearests[unover, :], distances[unover, :], p)
# 递归搜索右子数
nearests[unover, :], distances[unover, :] = self.search(X[unover, :], node.right, nearests[unover, :], distances[unover, :], p)
else:
# 依次遍历当前叶子结点包含的特征向量
for i in range(len(node.value)):
# 更新下标与距离记录的方式 k-d 树
dist = distance(X, node.value[i], p)
all_cond = np.zeros((X.shape[0],), dtype=np.bool)
for j in range(nearests.shape[1]):
cond = (~all_cond) & ((distances[:, j] < 0) | (dist - distances[:, j] < 0))
if (~cond).all():
continue
ns = np.insert(nearests[cond, :], j, node.index[i], axis=1)
nearests[cond, :] = ns[:,:-1]
ds = np.insert(distances[cond, :], j, dist[cond], axis=1)
distances[cond, :] = ds[:,:-1]
all_cond = all_cond | cond
if all_cond.all():
break
return nearests, distances
复制代码
使用 Python 实现 k 近邻分类
class knnc:
"""
k近邻分类器(使用 k-d 树和 Ball 树实现)
参数
----------
k : 最近邻的数量,默认为 5
leaf_size : 叶子节点包含的最大特征矩阵数量,默认为 10
p : 距离范数,默认为 2,即欧式距离
"""
def __init__(self, k = 5, leaf_size = 10, p = 2, tree = "kdtree"):
self.k = k
self.leaf_size = leaf_size
self.p = p
self.tree = tree
def fit(self, X, y):
"""
k近邻分类拟合
参数
----------
X : 特征矩阵
y : 标签分类
"""
if self.tree == "kdtree":
self._tree = kdtree(X, leaf_size = self.leaf_size)
else:
self._tree = balltree(X, leaf_size = self.leaf_size, p = self.p)
self.y = np.array(y)
self.y_classes = np.unique(y)
def predict(self, X):
"""
k近邻分类预测
参数
----------
X : 特征矩阵
"""
nearests, distances = self._tree.query(X, k = self.k, p = self.p)
predict_y = self.y[nearests]
predict_y_count = np.zeros((len(predict_y), len(self.y_classes)), dtype=np.int8)
for i, y_class in enumerate(self.y_classes):
predict_y_count[:, i] = np.sum(predict_y == y_class, axis=1)
return self.y_classes[np.argmax(predict_y_count, axis=1)]
复制代码
使用 Python 实现 k 近邻回归
class knnr:
"""
k近邻回归器(使用 k-d 树和 Ball 树实现)
参数
----------
k : 最近邻的数量,默认为 5
leaf_size : 叶子节点包含的最大特征矩阵数量,默认为 10
p : 距离函数参数,默认为 2,即欧式距离
"""
def __init__(self, k = 5, leaf_size = 10, p = 2, tree = "kdtree"):
self.k = k
self.leaf_size = leaf_size
self.p = p
self.tree = tree
def fit(self, X, y):
"""
k近邻回归拟合
参数
----------
X : 特征矩阵
y : 标签分类
"""
if self.tree == "kdtree":
self._tree = kdtree(X, leaf_size = self.leaf_size)
else:
self._tree = balltree(X, leaf_size = self.leaf_size, p = self.p)
self.y = np.array(y)
def predict(self, X):
"""
k近邻回归预测
参数
----------
X : 特征矩阵
"""
nearests, distances = self._tree.query(X, k = self.k, p = self.p)
predict_y = self.y[nearests]
return np.average(predict_y, axis=1)
复制代码
五、第三方库实现
scikit-learn6 实现 k 近邻分类
from sklearn.neighbors import KNeighborsClassifier
# k近邻分类器
clf = KNeighborsClassifier()
# 拟合数据集
clf = clf.fit(X, y)
复制代码
scikit-learn7 实现 k 近邻回归
from sklearn.neighbors import KNeighborsRegressor
# k近邻回归器
reg = KNeighborsRegressor(n_neighbors = 5)
# 拟合数据集
reg = reg.fit(X, y)
复制代码
六、示例演示
下面三张图展示了使用 k 近邻算法进行二分类,多分类与回归的结果
图6-1 图6-2 图6-3七、思维导图
图7-1八、参考文献
- en.wikipedia.org/wiki/K-near…
- en.wikipedia.org/wiki/Euclid…
- en.wikipedia.org/wiki/Minkow…
- en.wikipedia.org/wiki/K-d_tr…
- en.wikipedia.org/wiki/Ball_t…
- scikit-learn.org/stable/modu…
- scikit-learn.org/stable/modu…
完整演示请点击这里
注:本文力求准确并通俗易懂,但由于笔者也是初学者,水平有限,如文中存在错误或遗漏之处,恳请读者通过留言的方式批评指正
本文首发于——AI导图,欢迎关注