本文使用 python 实现 K近邻算法来对 sklearn 中的手写数字数据集进行分类
1. 前言
实现k近邻算法最简单的方法是:直接计算每个样本点到输入实例的距离, 选取最近的 K 个进行"多数表决", 从而得到输入实例的类别.
但当训练集的样本数增多时, 计算量也会急剧增大. 故为提高效率, 提出了kd树
2. kd树建树算法
假设有数据集 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 , n ] , j ∈ [ 1 , k ] x_i = (x_i^1, x_i^2, ... x_i^k)^T, i \in [1, n], j\in[1,k] xi=(xi1,xi2,...xik)T,i∈[1,n],j∈[1,k], k k k表示每个输入数据有 k k k个维度
- 选取根节点
x
i
x_i
xi, 进行划分
- 计算n个训练数据在k个维度上的方差, 选取方差最大的维度
x
j
x^j
xj作为坐标轴
- 数据方差最大表明沿该坐标轴方向上数据点分散得比较开,这个方向上进行数据分割可以获得最好的分辨率
- 然后将所有数据样本依据该维度得到中位数, 令其为根节点(划分点)
- 将对应维度的值中小于划分点的数据样本划为左子树, 大于该划分点的数据样本划为右子树
- 计算n个训练数据在k个维度上的方差, 选取方差最大的维度
x
j
x^j
xj作为坐标轴
- 对深度为n的节点, 重复选取根节点的步骤
- 直到两个子区域没有实例存在, 完成对kd树的划分
可能单独看文字不能理解, 下面可以结合代码进行理解
3. 建树代码
3.1 基本节点类
class Node():
"二叉树节点"
def __init__(self, data, left, right, dim):
self.data = data # 节点数据
self.left = left # 左节点
self.right = right # 右节点
self.dim = dim # 节点对应数据维度
3.2 建树函数
def buile_KDtree(data, ignore_columns=[-1]):
# ignore_columns是存储 作为划分维度的列以及需要忽略的列 的列表
# ignore_columns初始化为[-1], 是因为将train_labels添加到了train_data的最后一列, 在计算方差的时候需要忽略掉
if len(data) == 0:
return None # 当长度为零结束递归
temp = np.array(data, copy=True)
for col in ignore_columns:
temp[:, col] = 0
# 将指定的列的值赋为 0, 防止在计算方差的时候造成影响
max_var_dim = np.argmax(np.var(temp, axis=0))
#先计算每个维度的方差, axis = 1 表示对列求方差, 结果使得行维度被消除
"""
[[1, 2, 3],
[1, 2, 3], == > [0. 0. 0.]
[1, 2, 3]]
"""
# 再通过argmax得到最大值所对应的索引, 即训练数据中方差最大的维度对应的索引
new_ignore_columns = ignore_columns + [max_var_dim] # 更新 new_ignore_columns
# 对数据进行排序,以最大方差维度的数值排序
sorted_data = data[np.argsort(data[:, max_var_dim])]
# np.argsor(), 返回元素值从小到大排序后的索引值的数组
mid = sorted_data.shape[0] // 2 # 得到中间的索引, 即中位数对应的行
left_subtree = buile_KDtree(sorted_data[:mid], new_ignore_columns)
# 前mid行小于中位数, 进行递归建左子树
right_subtree = buile_KDtree(sorted_data[mid+1:], new_ignore_columns)
# 剩下的行大于中位数, 进行递归建右子树
return Node(sorted_data[mid], left_subtree, right_subtree, max_var_dim)
4. kd树预测算法
- 在构建好的kd树中找到包含目标点
x
x
x的叶节点
- 从根节点出发, 递归地向下访问kd树.
- 如果 x x x当前维度地坐标小于划分点的坐标, 就访问其左子节点, 反之访问其右节点
- 直到访问到叶节点
- 以此叶节点为"当前最近点"
- 递归的向上回退, 对回退过程中的每个节点进行下面的计算
- 如果"当前最近点"到 x x x的距离大于到其对应维度的坐标轴的距离, 说明在"当前最近点"的兄弟邻域可能存在距离更小的点
- 这时就需要递归到兄弟邻域进行计算
- 一直递归直至回退到根节点结束
可能单独看文字不能理解, 下面可以结合代码进行理解
5.预测代码
def predict(kd_tree, x, k, p, distance_function):
"""
parameters:
kd_tree: 建好的kd树
x: 测试数据
k: 选取k个最近邻的点进行'举手表决'
p: 计算距离时使用的参数
distance_function: 选取k个最近邻点的距离计算函数
return:
返回k个最近邻点的标签
"""
heap_k = [(-np.inf, None)] * k
- 我们在递归中需要找到离 x x x距离最小的点, 将这个点插入堆, 并将堆中值最大的数弹出.
- python中默认实现的是最小堆(根结点为最小值, 叶节点大于其父节点), 每次弹出的是最小值(根节点)
- 故而我们将距离取负值, 每次弹出的就是最大的距离(最大距离对应的负值最小)
heap_k
就是最大堆,包含k个元素(选取k个节点进行"举手表决"),(-np.inf, None)
对应距离和标签, 距离初始化为负无穷(最小值), 弹出就会优先弹出
# 函数内的函数, 用来遍历节点和更新堆
def traverse_and_update_heap(node):
if node is None:
return
dis_with_axis = x[node.dim] - node.data[node.dim]
""""
node.dim是划分当前节点时的维度
可以根据dis_with_axis的正负来判断当前节点在左子树还是右子树
"""
traverse_and_update_heap(node.left if dis_with_axis < 0 else node.right)
# 小于 0, 就向左子树递归
dis_with_node = distance_function(x.reshape((1, -1)), node.data.reshape((1,-1))[:,:-1], p)
# 计算出 x 到当前节点的距离
heapq.heappushpop(heap_k,(-dis_with_node,node.data[-1]))
"""
(-dis_with_node,node.data[-1])即(距离的负值, label)
heapq.heappushpop(): 将新元素压入堆中,然后弹出并返回堆中的值最小的元素
弹出的值最小元素,即是距离最大的元素(负距离)
"""
if -heap_k[0][0] > abs(dis_with_axis):
# 如果堆中的最大距离大于到分割轴的距离, 则说明在该轴另一半边可能还有距离更近的点
traverse_and_update_heap(node.right if dis_with_axis < 0 else node.left)
# 到另外半边进行搜寻
不知道有没有同学对于这句代码if -heap_k[0][0] > abs(dis_with_axis):
是否有疑惑.
- 前面
-heap_k[0][0]
是点到点的距离, 综合了所有维度 - 后面
abs(dis_with_axis)
是两点对应维度间数值的差值, 即同一维度间的距离
为什么两个距离的计算方式都不同, 但可以进行比较呢? 可能我表达能力不是很强, 我就画图来表示了
所以需要递归到另一子节点进行搜寻, 看是否有距离更小的点
traverse_and_update_heap(kd_tree) # 从根节点开始递归
k_list = [int(x[1]) for x in heapq.nlargest(k, heap_k)]
# heapq.nlargest(k, heap_k): 从 heap_k 堆中取出最大的k个元素
# 因为是负距离, 所以取出了距离最小的k个值
return k_list
4. 其他代码
def lp_distance(m, n, p):
"""
计算m与n之间的LP距离:
公式: Lp(m, n) = (|m1-n1|^p + |m2-n2|^p + ... |m_K-n_k|)^(1/p)
m与n均有k个维度
"""
return np.sum(np.abs(m-n)**p)**(1/p)
def vote(k_list):
"""投票表决, 返回列表中出现次数最多的值"""
count={}
for i in k_list:
count[i]=count.get(i,0)+1 # 若count中有i, 就返回它的值, 反之返回0
sorted_cout = sorted(count.items(), key=lambda x: x[1], reverse=True) # 根据value进行排序
# count.items()返回的值: [(key1, value1), (key2, value2),...]
return sorted_cout[0][0]
def shuffle_split_dataset(features, labels, random_seed, split_ratio):
np.random.seed(random_seed) # 设置随机数种子
# 随机打乱数据
shuffle_indices = np.random.permutation(features.shape[0])
features = features[shuffle_indices]
labels = labels[shuffle_indices]
# 划分训练、测试集
train_count = int(len(features)*split_ratio)
train_data, train_labels = features[:train_count], labels[:train_count]
test_data, test_labels = features[train_count:], labels[train_count:]
return (train_data, train_labels, test_data, test_labels)
# 主函数
digits = load_digits()
return_data = shuffle_split_dataset(digits.data, digits.target, 0, 0.8)
train_data, train_labels, test_data, test_labels = return_data
data = np.concatenate([train_data, train_labels.reshape((-1, 1))], axis=-1)
# (1437, 64)与(1437, 1) 按最后一个维度(列)进行拼接, 结果的形状为[1437, 65](最后一列为标签)
# 将训练数据与标签拼接在一起, 防止后续的变换让数据与对应标签错乱
k = 5 # k个最近邻点
p = 2 # 使用L2范数, 欧氏距离
tree = buile_KDtree(data) # 建树
# 对训练集进行验证
predict_labels = []
for test_x in tqdm(test_data):
k_list = predict(tree, test_x, k, p, lp_distance)
predict_label = vote(k_list)
predict_labels.append(predict_label)
predict_labels = np.array(predict_labels)
acc = (predict_labels == test_labels).sum() / test_labels.shape[0]
print("acc:", acc)
"""
输出
100%|██████████| 360/360 [00:04<00:00, 82.87it/s]
acc: 0.9833333333333333
"""
5. 完整代码
from sklearn.datasets import load_digits
import numpy as np
from tqdm import tqdm
import heapq
class Node():
"二叉树节点"
def __init__(self, data, left, right, dim):
self.data = data # 节点数据
self.left = left # 左节点
self.right = right # 右节点
self.dim = dim # 节点对应数据维度
class KD_Tree():
def __init__(self, data):
self.root = self.buile_KDtree(data) # 根节点
def buile_KDtree(self, data, ignore_columns=[-1]):
# ignore_columns是存储 作为划分维度的列以及需要忽略的列 的列表
# ignore_columns初始化为[-1], 是因为将train_labels添加到了train_data的最后一列, 在计算方差的时候需要忽略掉
if len(data) == 0:
return None # 当长度为零结束递归
temp = np.array(data, copy=True)
for col in ignore_columns:
temp[:, col] = 0
# 将指定的列的值赋为 0, 防止在计算方差的时候造成影响
max_var_dim = np.argmax(np.var(temp, axis=0))
#先计算每个维度的方差, axis = 1 表示对列求方差, 结果使得行维度被消除
"""
[[1, 2, 3],
[1, 2, 3], == > [0. 0. 0.]
[1, 2, 3]]
"""
# 再通过argmax得到最大值所对应的索引, 即训练数据中方差最大的维度对应的索引
new_ignore_columns = ignore_columns + [max_var_dim] # 更新 new_ignore_columns
# 对数据进行排序,以最大方差维度的数值排序
sorted_data = data[np.argsort(data[:, max_var_dim])]
# np.argsor(), 返回元素值从小到大排序后的索引值的数组
mid = sorted_data.shape[0] // 2 # 得到中间的索引, 即中位数对应的行
left_subtree = self.buile_KDtree(sorted_data[:mid], new_ignore_columns)
# 前mid行小于中位数, 进行递归建左子树
right_subtree = self.buile_KDtree(sorted_data[mid+1:], new_ignore_columns)
# 剩下的行大于中位数, 进行递归建右子树
return Node(sorted_data[mid], left_subtree, right_subtree, max_var_dim)
def predict(self, x, k, distance_function):
"""
parameters:
kd_tree: 建好的kd树
x: 测试数据
k: 选取k个最近邻的点进行'举手表决'
p: 计算距离时使用的参数
distance_function: 选取k个最近邻点的距离计算函数
return:
返回k个最近邻点的标签
"""
heap_k = [(-np.inf, None)] * k
# 函数内的函数, 用来遍历节点和更新堆
def traverse_and_update_heap(node):
if node is None:
return
dis_with_axis = x[node.dim] - node.data[node.dim]
""""
node.dim是划分当前节点时的维度
可以根据dis_with_axis的正负来判断当前节点在左子树还是右子树
"""
traverse_and_update_heap(node.left if dis_with_axis < 0 else node.right)
# 小于 0, 就向左子树递归
dis_with_node = distance_function(x.reshape((1, -1)), node.data.reshape((1,-1))[:,:-1])
# 计算出 x 到当前节点的距离
heapq.heappushpop(heap_k,(-dis_with_node,node.data[-1]))
"""
(-dis_with_node,node.data[-1])即(距离的负值, label)
heapq.heappushpop(): 将新元素压入堆中,然后弹出并返回堆中的值最小的元素
弹出的值最小元素,即是距离最大的元素(负距离)
"""
if -heap_k[0][0] > abs(dis_with_axis):
# 如果堆中的最大距离大于到分割轴的距离, 则说明在该轴另一半边可能还有距离更近的点
traverse_and_update_heap(node.right if dis_with_axis < 0 else node.left)
# 到另外半边进行搜寻
traverse_and_update_heap(self.root) # 从根节点开始递归
k_list = [int(x[1]) for x in heapq.nlargest(k, heap_k)]
# heapq.nlargest(k, heap_k): 从 heap_k 堆中取出最大的k个元素
# 因为是负距离, 所以取出了距离最小的k个值
return k_list
class KNN():
def __init__(self, k, p, data):
self.k = k
self.p = p
self.data = data
self.kdtree = None
def lp_distance(self, m, n):
"""
计算m与n之间的LP距离:
公式: Lp(m, n) = (|m1-n1|^p + |m2-n2|^p + ... |m_K-n_k|)^(1/p)
m与n均有k个维度
"""
return np.sum(np.abs(m-n)**self.p)**(1/self.p)
def vote(self, k_list):
"""投票表决, 返回列表中出现次数最多的值"""
count={}
for i in k_list:
count[i]=count.get(i,0)+1 # 若count中有i, 就返回它的值, 反之返回0
sorted_cout = sorted(count.items(), key=lambda x: x[1], reverse=True) # 根据value进行排序
# count.items()返回的值: [(key1, value1), (key2, value2),...]
return sorted_cout[0][0]
def kdtree_test(self, test_data):
if self.kdtree is None:
self.kdtree = KD_Tree(data)
predict_labels = []
for test_x in tqdm(test_data):
k_list = self.kdtree.predict(test_x, self.k, self.lp_distance)
predict_label = self.vote(k_list)
predict_labels.append(predict_label)
return np.array(predict_labels)
def shuffle_split_dataset(features, labels, random_seed, split_ratio):
np.random.seed(random_seed) # 设置随机数种子
# 随机打乱数据
shuffle_indices = np.random.permutation(features.shape[0])
features = features[shuffle_indices]
labels = labels[shuffle_indices]
# 划分训练、测试集
train_count = int(len(features)*split_ratio)
train_data, train_labels = features[:train_count], labels[:train_count]
test_data, test_labels = features[train_count:], labels[train_count:]
return (train_data, train_labels, test_data, test_labels)
if __name__ == '__main__':
digits = load_digits()
return_data = shuffle_split_dataset(digits.data, digits.target, 0, 0.8)
train_data, train_labels, test_data, test_labels = return_data
data = np.concatenate([train_data, train_labels.reshape((-1, 1))], axis=-1)
# (1437, 64)与(1437, 1) 按最后一个维度(列)进行拼接, 结果的形状为[1437, 65](最后一列为标签)
# 将训练数据与标签拼接在一起, 防止后续的变换让数据与对应标签错乱
k = 5
p = 2
knn = KNN(k, p, data)
predict_labels = knn.kdtree_test(test_data)
acc = (predict_labels == test_labels).sum() / test_labels.shape[0]
print('acc: %.4f'%acc)
6. sklearn
代码实现
from sklearn.neighbors import KDTree
tree = KDTree(train_data)
indexs = tree.query(test_data, k=5, return_distance=False)
predect = []
for row in indexs:
predect_list = [train_labels[i] for i in row]
count={}
for i in predect_list:
count[i]=count.get(i,0)+1
sorted_cout = sorted(count.items(), key=lambda x: x[1], reverse=True)
predect.append(sorted_cout[0][0])
acc = (predect == test_labels).sum() / test_labels.shape[0]
print('acc: %.4f'%acc)
"""
输出:
acc: 0.9833
"""
7. 结语
- 这篇文章大部分依旧是一位大佬的代码,我只是对其进行了解释和添加了注释, 但是已经找不到原作者了, 如果有同学发现请告诉我
- 如有发现错误, 请狠狠的告诉我! 感谢阅读.