机器学习: 简易kd树的实现

本文使用 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个维度

  1. 选取根节点 x i x_i xi, 进行划分
    1. 计算n个训练数据在k个维度上的方差, 选取方差最大的维度 x j x^j xj作为坐标轴
      • 数据方差最大表明沿该坐标轴方向上数据点分散得比较开,这个方向上进行数据分割可以获得最好的分辨率
    2. 然后将所有数据样本依据该维度得到中位数, 令其为根节点(划分点)
    3. 将对应维度的值中小于划分点的数据样本划为左子树, 大于该划分点的数据样本划为右子树
  2. 对深度为n的节点, 重复选取根节点的步骤
  3. 直到两个子区域没有实例存在, 完成对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树预测算法

  1. 在构建好的kd树中找到包含目标点 x x x的叶节点
    • 从根节点出发, 递归地向下访问kd树.
    • 如果 x x x当前维度地坐标小于划分点的坐标, 就访问其左子节点, 反之访问其右节点
    • 直到访问到叶节点
  2. 以此叶节点为"当前最近点"
  3. 递归的向上回退, 对回退过程中的每个节点进行下面的计算
    • 如果"当前最近点"到 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
  1. 我们在递归中需要找到离 x x x距离最小的点, 将这个点插入堆, 并将堆中值最大的数弹出.
  2. python中默认实现的是最小堆(根结点为最小值, 叶节点大于其父节点), 每次弹出的是最小值(根节点)
  3. 故而我们将距离取负值, 每次弹出的就是最大的距离(最大距离对应的负值最小)
  4. 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. 结语

  • 这篇文章大部分依旧是一位大佬的代码,我只是对其进行了解释和添加了注释, 但是已经找不到原作者了, 如果有同学发现请告诉我
  • 如有发现错误, 请狠狠的告诉我! 感谢阅读.
  • 17
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值