手写数字识别实践补充:基于KNN与PyQt5

  本文完成于博文手写数字识别实践(二)之前,类似于学习分支吧,算是自己做的一个拓展学习(当然也不排除老师后续发布相关任务直接转正的可能)。

一、理论基础

  K-近邻( K-Nearest Neighbor,简称KNN)学习是一种常用的监督学习方法,其工作机制非常简单:给定测试样本,基于某种距离度量找出训练集中与其最靠近的 k k k 个训练样本,然后基于这 k k k 个“邻居”的信息来进行预测。
  很明显,和朴素贝叶斯方法相比,K-近邻学习有一个明显的不同之处:它没有显式的训练过程。作为“懒惰学习”的代表,它在训练阶段仅仅是把样本保存起来,直到收到测试样本后才开始处理(这种处理方式其实已经决定了K-近邻学习的执行速度)。

1.K-近邻算法

输入:训练数据集 T = { ( x 1 , y 1 ) , ( x 2 , y 2 ) , . . . , ( x N , y N ) } T=\lbrace(x_1,y_1),(x_2,y_2),...,(x_N,y_N)\rbrace T={(x1,y1),(x2,y2),...,(xN,yN)}   其中, x i ∈ X = { x 1 , x 2 , . . . , x N } x_i \in X=\lbrace x_1,x_2,...,x_N\rbrace xiX={x1,x2,...,xN}(样本集), y i ∈ Y = { c 1 , c 2 , . . . , c K } y_i \in Y=\lbrace c_1,c_2,...,c_K\rbrace yiY={c1,c2,...,cK}(类别)
输出:实例 x x x 所属的类 y y y
(1)根据给定的距离变量,在训练集 T T T 中找出与 x x x 最邻近的 k k k 个点,涵盖这 k k k 个点的 x x x 邻域记作 N K ( x ) N_K(x) NK(x)
(2)在 N K ( x ) N_K(x) NK(x) 中根据分类决策规则(如多数表决)决定 x x x 的类别 y y y y = a r g m a x y j ∑ x i ∈ N K ( x ) I ( y i = c j ) , i = 1 , 2 , . . , N ; j = 1 , 2 , . . . , K y = argmax_{y_j} \sum_{x_i \in N_K(x)}I(y_i = c_j),i=1,2,..,N;j=1,2,...,K y=argmaxyjxiNK(x)I(yi=cj),i=1,2,..,N;j=1,2,...,K

2.K-近邻模型

  从K-近邻算法可以看出,K-近邻方法的三个基本要素为给定的距离变量(距离度量)选择的 k k k 个点( k k k 值)分类决策规则
(1)距离度量: L ( x i , x j ) = ( ∑ l = 1 n ∣ x i l − x j l ∣ p ) 1 p L(x_i,x_j)=\left( \sum_{l=1}^n|x_i^l-x_j^l|^p \right)^{\frac 1p} L(xi,xj)=(l=1nxilxjlp)p1    p ≥ 1 p \geq 1 p1 时,上式为闵可夫斯基距离( L p L_p Lp , p p p 等于1时也称为曼哈顿距离 L 1 L_1 L1); p = 2 p = 2 p=2 时为欧氏距离( L 2 L_2 L2); p = ∞ p= \infin p= 时为切比雪夫距离(各个坐标距离的最大值)。
(2) k k k 值:简单来说, k k k 越小,预测时可以凭依的信息量就越少,近邻的实例点对预测结果的影响就越大。但是如果把 k k k 取得太大,就会把与输入实例距离较远的(不相似)的训练实例也考虑进来,同样会给预测带来影响。
  一般情况下我们会取一个比较小的 k k k 值,然后使用交叉验证法选取最优的 k k k 值。
(3)分类决策规则:采用常规的多数表决规则,也就是取这 k k k 个值对应类别的众数作为决策结果(一句话就能说清楚的事非要绕着讲一小段外加数个意义不明的公式累不累啊淦)

ps:事实上到这里就已经可以开始着手实现K-近邻分类器了。不过正如之前所说,K-近邻的数据处理机制就已经决定了分类器学习的速度,如果不在数据结构上加以优化而选择强撸(线性暴搜)的话,代码执行效率低下,运行时间堪称感人(我找过几个老哥关于K-近邻方法的博客,好家伙跑一次要20~30分钟,朴素贝叶斯跑7w张图用了3分钟我都等得有点不耐烦了)

3.KD树

  KD树是K-dimension tree的缩写,相当于二叉树的多维化,用于多维空间的数据检索。要详细介绍的话内容有点多,这里只介绍KD树的构建算法,详细情况可参考:KNN(三)–KD树详解及KD树最近邻算法
输入: k k k 维空间数据集 T = { x 1 , x 2 , . . . , x N } T=\lbrace x_1,x_2,...,x_N \rbrace T={x1,x2,...,xN},其中 x i = ( x i 1 , x i 2 , . . . , x i k ) x_i=(x_i^1,x_i^2,...,x_i^k) xi=(xi1,xi2,...,xik)
输出:KD树
(1)确定 split 域:对于所有描述子数据(特征矢量),统计它们在每个维上的数据方差,选取方差最大的维作为 split 域的值(方差最大意味着各子数据在这个维上最分散,在这个维上展开进行数据分割的效果最好)。
(2)确定 Node-data(KD树根节点):根据选定的 split 域对所有数据进行排序,取正中间的数据点作为 Node-data 。
(3)确定左子空间和右子空间:Node-data 左边的即是左子空间,右边的则为右子空间。
  通过上述步骤的递归,直到左右子空间内无实例存在时即完成 KD树的构建。

4.K-近邻搜索算法

输入:已构造的KD树,目标点 x x x ,给定 k k k
输出: x x x 的前 k k k 个近邻点队列
(1)构造近邻点列表
(2)找出包含目标点 x x x 的叶结点(即包含输入样例的超矩形区域):若 x x x 当前维的坐标小于切分点坐标,则移动到左子结点,否则移动到右子结点,直到子结点为叶结点
(3)以该叶结点为”当前最近点“,计算判别距离,并追加到近邻点列表中
(4)从该叶结点开始递归向上回溯,在每个结点进行以下操作:
  a.计算目标点与当前结点的父结点的距离,更新判别距离并确定是否将其追加到近邻点列表中
  b.以目标点为圆心/球心,以判别距离为半径画圆/超球体,判断其是否与父结点的切分超平面相交。如果相交,则说明在父结点的另一个子结点(即当前结点的兄弟结点)中可能存在目标点的近邻点,因此进入兄弟结点中继续搜索(每次追加完新的结点到近邻点列表以后需要重新对列表排序,并更新判别距离)
(5)当返回到根结点时,搜索结束,取近邻点列表的前 k k k 个元素,统计归属的类别,取最大类别数对应的类别最为预测结果

二、程序设计实践

1. 数据预处理

  大致与博文手写数字识别实践(一):基于朴素贝叶斯分类器与PyQt5中的方法相同(没错就是懒,不想降维),唯一不同的就是使用K-近邻方法时在构建KD树的过程中需要选定方差最大的特征维数进行展开,涉及到对计算结果的排序,因此博主没有选择将图像二值化(追加:一开始觉得除了1就是0就没有什么区分度,后来一时兴起把二值化的图扔进去发现影响似乎不是很大),所以就把之前的 python 脚本中二值化的部分注释掉,重新生成了训练集和数据集的 csv 文件。

2. 模型训练(实为构建KD树)

class Node(object):
    '''结点对象'''
    def __init__(self, item=None, label=None, dim=None, parent=None, left_child=None, right_child=None):
        self.item = item                # 结点的值(样本信息)
        self.label = label              # 结点的标签
        self.dim = dim                  # 结点的切分的维度(特征)
        self.parent = parent            # 父结点
        self.left_child = left_child    # 左子树
        self.right_child = right_child  # 右子树
 
class KDTree(object):

    def __init__(self, aList, labelList):
        self.__length = 0                               # 不可修改
        self.__root = self.__create(aList,labelList)    # 根结点, 私有属性, 不可修改
 
    def __create(self, aList, labelList, parentNode=None):
        '''
        创建kd树
        :param aList: 需要传入一个类数组对象(行数表示样本数,列数表示特征数)
        :labellist: 样本的标签
        :parentNode: 父结点
        :return: 根结点
        '''
        dataArray = np.array(aList)
        m,n = dataArray.shape
        labelArray = np.array(labelList).reshape(m,1)
        if m == 0:  # 样本集为空
            return None
        # 求所有特征的方差,选择方差最大的那个特征作为切分超平面
        var_list = [np.var(dataArray[:,col]) for col in range(n)]   # 获取每一个特征的方差
        max_index = var_list.index(max(var_list))                   # 获取最大方差特征的索引
        # 样本按最大方差特征进行升序排序后,取出位于中间的样本
        max_feat_ind_list = dataArray[:,max_index].argsort()
        mid_item_index = max_feat_ind_list[m // 2]
        # 生成结点
        if m == 1:  # 样本数为1时,返回自身
            self.__length += 1
            return Node(dim=max_index,label=labelArray[mid_item_index], item=dataArray[mid_item_index], parent=parentNode, left_child=None, right_child=None)
        else:
            node = Node(dim=max_index, label=labelArray[mid_item_index], item=dataArray[mid_item_index], parent=parentNode, )
        # 构建有序的子树
        left_tree = dataArray[max_feat_ind_list[:m // 2]]               # 左子树
        left_label = labelArray[max_feat_ind_list[:m // 2]]             # 左子树标签
        left_child = self.__create(left_tree,left_label,node)
        if m == 2:  # 只有左子树,无右子树(在只有两个数据传入构建函数的情况下,被取作根节点的一定是更大的那个)
            right_child = None
        else:
            right_tree = dataArray[max_feat_ind_list[m // 2 + 1:]]      # 右子树
            right_label = labelArray[max_feat_ind_list[m // 2 + 1:]]    # 右子树标签
            right_child = self.__create(right_tree,right_label,node)
        # 左右子树递归调用自己,返回子树根结点
        node.left_child = left_child
        node.right_child = right_child
        self.__length += 1      #KD树总结点数
        return node

3. 模型预测(查找前k个近邻点,统计分析)

def _find_nearest_neighbour(self, item):
    if self.length == 0:  # 空kd树
        return None
    # 递归找离测试点最近的叶结点
    node = self.root
    if self.length == 1: # 只有一个样本
        return node
    while True:
        cur_dim = node.dim
        if item[cur_dim] < node.item[cur_dim]:  # 进入左子树
            if node.left_child == None:  # 左子树为空,返回自身
                return node
            node = node.left_child
        else:
            if node.right_child == None:  # 右子树为空,返回自身
                return node
            node = node.right_child

def knn_algo(self, item, k=1):
    '''
    找到距离测试样本最近的前k个样本
    :param item: 测试样本
    :param k: knn算法参数,定义需要参考的最近点数量,一般为1-5
    :return: 返回前k个样本的最大分类标签
    '''
    item = np.array(item)
    node = self._find_nearest_neighbour(item)  # 找到最邻近点的近似叶结点
    if node == None:  # 空树
        return None
    node_list = []
    distance = np.sqrt(sum((item-node.item)**2))  # 测试点与该叶结点之间的距离(欧氏距离)
    least_dis = distance                          # 将其作为最大距离
    node_list.append([distance, tuple(node.item), node.label[0]])  # 需要将距离与结点一起保存起来
    # 回到父结点,判断以测试点为圆心,distance为半径的圆是否与父结点分隔超平面相交,若相交,则说明父结点的另一个子树可能存在更近的点
    while True:
        if node == self.root:  # 已经回到kd树的根结点
            break
        parent = node.parent
        # 计算测试点与父结点的距离,与上面距离做比较
        par_dis = np.sqrt(sum((item-parent.item)**2))
        if k >len(node_list) or par_dis < least_dis:  # 父结点距离小于结点列表中最大的距离
            node_list.append([par_dis, tuple(parent.item) , parent.label[0]])
            node_list.sort()  # 对结点列表按距离排序
            least_dis = node_list[-1][0] if k >= len(node_list) else node_list[k - 1][0]

        # 判断父结点的另一个子树与结点列表中最大的距离构成的圆是否有交集
        if k >len(node_list) or abs(item[parent.dim] - parent.item[parent.dim]) < least_dis :       # 说明父结点的另一个子树与圆有交集
            other_child = parent.left_child if parent.left_child != node else parent.right_child    # 找另一个子树
            if other_child != None:
                if item[parent.dim] - parent.item[parent.dim] <= 0:
                    self.left_search(item,other_child,node_list,k)  # 测试点在该子结点超平面的左侧
                else:
                    self.right_search(item,other_child,node_list,k)  # 测试点在该子结点平面的右侧

        node = parent  # 否则继续返回上一层
    # 接下来取出前k个元素中最大的分类标签
    label_dict = {}
    node_list = node_list[:k]
    # 获取所有label的数量
    for element in node_list:
        if element[2] in label_dict:
            label_dict[element[2]] += 1
        else:
            label_dict[element[2]] = 1
    sorted_label = sorted(label_dict.items(), key=lambda item:item[1], reverse=True)  # 给标签排序
    return sorted_label[0][0],node_list

def left_search(self, item, node, nodeList, k):
    '''
    按左中右顺序遍历子树结点,返回结点列表
    :param node: 子树结点
    :param item: 传入的测试样本
    :param nodeList: 结点列表
    :param k: 搜索比较的结点数量
    :return: 结点列表
    '''
    nodeList.sort()  # 对结点列表按距离排序
    least_dis = nodeList[-1][0] if k >= len(nodeList) else nodeList[k - 1][0]
    if node.left_child == None and node.right_child == None:  # 叶结点
        dis = np.sqrt(sum((item - node.item) ** 2))
        if k > len(nodeList) or dis < least_dis:
            nodeList.append([dis, tuple(node.item), node.label[0]])
        return
    self.left_search(item, node.left_child, nodeList, k)
    # 每次进行比较前都更新nodelist数据
    nodeList.sort()  # 对结点列表按距离排序
    least_dis = nodeList[-1][0] if k >= len(nodeList) else nodeList[k - 1][0]
    # 比较根结点
    dis = np.sqrt(sum((item-node.item)**2))
    if k > len(nodeList) or dis < least_dis:
        nodeList.append([dis, tuple(node.item), node.label[0]])
    # 右子树
    if k > len(nodeList) or abs(item[node.dim] - node.item[node.dim]) < least_dis: # 需要搜索右子树
        if node.right_child != None:
            self.left_search(item, node.right_child, nodeList, k)

    return nodeList

def right_search(self,item, node, nodeList, k):
    '''
    按右中左顺序遍历子树结点
    :param item: 测试的样本点
    :param node: 子树结点
    :param nodeList: 结点列表
    :param k: 搜索比较的结点数量
    :return: 结点列表
    '''
    nodeList.sort()  # 对结点列表按距离排序
    least_dis = nodeList[-1][0] if k >= len(nodeList) else nodeList[k - 1][0]
    if node.left_child == None and node.right_child == None:  # 叶结点
        dis = np.sqrt(sum((item - node.item) ** 2))
        if k > len(nodeList) or dis < least_dis:
            nodeList.append([dis, tuple(node.item), node.label[0]])
        return
    if node.right_child != None:
        self.right_search(item, node.right_child, nodeList, k)

    nodeList.sort()  # 对结点列表按距离排序
    least_dis = nodeList[-1][0] if k >= len(nodeList) else nodeList[k - 1][0]
    # 比较根结点
    dis = np.sqrt(sum((item - node.item) ** 2))
    if k > len(nodeList) or dis < least_dis:
        nodeList.append([dis, tuple(node.item), node.label[0]])
    # 左子树
    if k > len(nodeList) or abs(item[node.dim] - node.item[node.dim]) < least_dis: # 需要搜索左子树
        self.right_search(item, node.left_child, nodeList, k)

    return nodeList
 
def Test_Predict(testset, test_labels, kd_tree, k=1):
    pre_right = np.zeros(10)
    act_numbers = np.zeros(10)
    predict = []
    accuracy = []

    for i in test_labels:
        act_numbers[i] += 1

    for i in range(len(test_labels)):
        label, _ = kd_tree.knn_algo(testset[i], k)
        predict.append(label)
        if label == test_labels[i]:
            pre_right[label] += 1
        if (i+1) % 5 == 0:
            accuracy.append(float(pre_right.sum())/(i+1))

    print(pre_right)
    print('-------------------------------------------------------------------')
    print(act_numbers)
    print('-------------------------------------------------------------------')
    print(pre_right/act_numbers)

    plt.figure()
    plt.plot(np.arange(1,21),accuracy,marker="o",markersize=8)
    plt.xlabel("x -- 1:5")
    plt.title("Predict Accuracy Rate")
    plt.show()

    return accuracy, np.array(predict)

三、实际效果

  取1w个训练样本构建KD树,100个测试样本,k = 5(建树吞内存吞得比较厉害,就没敢跑太大的数据量):
在这里插入图片描述
  跑的测试样本感觉有点少了,导致指标没有什么参考价值。投入的测试样本数量可以再加一些,不过测试时间也相应地会被拉长。博主在复习阶段就不花太多时间精调了,重点在于大体理解和能够应用。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

PS:K-近邻分类用起来感受就两个字,稳,慢(毕竟没有在特征维数上优化,构建KD树还是花了一部分时间的)。2020年12月21日补充:已经在线性SVM的博客中提及了使用PCA降维的实现方法,但因为K-近邻并非作业而是拓展,没有必须优化的需求。如有需要可移步参考后自行插入相关处理环节:手写数字识别实践(二):基于线性SVM与PyQt5

四、参考资料

五、复习阶段追加——决策树模型

  原本的K-近邻和决策树是两种不同的方法(上文介绍的是对数据结构进行过优化的快速K-近邻),但用过KD树以后就觉得这俩还是有点相似的,就把内容追加到这里了。
  决策树( Decision Tree)是一种基本的分类与回归方法,一种逼近离散函数值的方法。决策树模型呈树形结构,其中每个内部结点都表示一个属性上的判断,每个分支代表一个判断结果的输出,最后每个叶结点代表一种分类结果。

1.决策树学习基本算法

  决策树的构建思路很简单,这里先贴出基本的递归算法,后续对算法展开说明。
在这里插入图片描述
  首先是一些可以帮助阅读算法的说明:

  • 属性集 A A A 中包含样本点的所有属性 a i a_i ai,而对于每个属性 a i a_i ai V V V 个可能的取值 { a i 1 , a i 2 , . . . , a i V } \lbrace a_i^1,a_i^2,...,a_i^V\rbrace {ai1,ai2,...,aiV}
  • 如果使用某一个属性 a a a 来对样本集 D D D 进行划分,则会产生 V V V 个分支结点,其中第 v v v 个分支结点包含了 D D D 中所有在属性 a a a 上取值为 a v a^v av 的样本,记为 D v D^v Dv

  可以看到在第3行、第6行和第12行,一共有3个return,分别对应三种不同的情况:(1)当前结点包含的样本全属于同一类别,因此无需划分,可以直接将该结点作为叶结点提交,并标记为对应的类别;(2)当前属性集为空(无可用来划分的属性),或是所有样本在所有属性上取值相同,无法划分,因此将该结点作为叶结点提交,并标记为结点中所含样本最多的类别;(3)当前结点包含的样本集合为空,不能划分,因此将该界定啊作为叶结点提交,并标记为父结点中所含样本最多的类别。

2.最优划分特征选择

  不难看出,决策树模型构建的关键就在算法的第8行——选择当前结点的最优划分特征。这里涉及到了两个前置概念:信息熵和信息增益。
  随着划分过程的不断进行,咱自然是希望分支结点包含的样本尽可能属于同一类别,这也就意味着结点的“纯度”越来越高。而信息熵就是度量样本集合纯度的一种常用指标。假定当前样本集合 D D D 中第 k k k 类样本占比为 p k ( k = 1 , 2 , . . . , m ) p_k(k=1,2,...,m) pk(k=1,2,...,m),则 D D D 的信息熵定义为
E n t ( D ) = − ∑ k = 1 m p k log ⁡ 2 p k Ent(D)=-\sum_{k=1}^m p_k\log_2p_k Ent(D)=k=1mpklog2pk   E n t ( D ) Ent(D) Ent(D) 的值越小, D D D 的纯度就越高。

PS:规定当 p k = 0 p_k=0 pk=0 时, p k log ⁡ 2 p k = 0 p_k\log_2p_k=0 pklog2pk=0

  基于信息熵的概念基础,引出信息增益的定义
G a i n ( D , a ) = E n t ( D ) − ∑ v = 1 V ∣ D v ∣ ∣ D ∣ E n t ( D v ) Gain(D,a)=Ent(D)-\sum_{v=1}^V\frac{|D^v|}{|D|}Ent(D^v) Gain(D,a)=Ent(D)v=1VDDvEnt(Dv)  乍一看公式可能有点复杂,但实际上它要表达的意思很简单:在当前结点把样本集 D D D 依据属性 a a a 划分为 V V V 个分支结点后,该结点处的信息熵就发生了变化,姑且认为划分后该结点的信息熵就是划分出来的 V V V 个分支结点的信息熵之和,那么信息增益反映的就i是信息熵的增量。而在每个分支结点的信息熵前面除以一个 ∣ D ∣ |D| D 只是人为赋予了一个权重,即样本数越多的分支结点影响越大。
  到这里,选择最优划分特征的标注就出来了。即在当前结点,计算属性集中所有属性对于该结点划分时的信息增益,信息增益最大的属性带来的划分效果就最为理想:
a ∗ = argmax ⁡ a ∈ A   G a i n ( D , a ) a_*={\underset {a∈A}{\operatorname {argmax} }}\,Gain(D,a) a=aAargmaxGain(D,a)

3.剪枝

  当决策树模型在训练数据中生成了过于复杂的树结构,就有很大概率造成过拟合。剪枝则可以有效缓解过拟合的副作用。决策树剪枝的基本策略有预剪枝和后剪枝两种。

  • 预剪枝是指在决策树生成过程中,对每个结点在划分前先进行估计,若当前结点的划分不能带来决策树泛化性能的提升,则停止划分并将当前结点标记为叶结点;
  • 后剪枝是先从训练集生成一颗完整的决策树,然后自底向上对非叶节点进行考察,若将该结点对应的子树替换为叶结点能带来决策树的泛化性能提升,则将该子树替换为叶结点。

PS:评估泛化性能,最直观的方式就是看剪枝后模型的测试准确率的变化情况。

4.ID3、C4.5和CART算法

  文中主要介绍的部分就是 ID3 决策树学习算法。C4.5 和 ID3 的主要不同之处在于它不直接使用信息增益,而是增加了一个名为增益率的指标来选择最优划分属性。
G a i n _ r a t i o ( D , a ) = G a i n ( D , a ) − ∑ v = 1 V ∣ D v ∣ ∣ D ∣ log ⁡ 2 ∣ D v ∣ ∣ D ∣ Gain\_ratio(D,a)=\frac{Gain(D,a)}{-\sum_{v=1}^V\frac{|D^v|}{|D|}\log_2\frac{|D^v|}{|D|}} Gain_ratio(D,a)=v=1VDDvlog2DDvGain(D,a)  但是,增益率准则对可取值数目较少的属性有所偏好(易知 V V V 越大分母通常就越大,如此增益率就更小一些),因此 C4.5 算法也不是直接选择增益率最大的候选划分属性,而是先从候选划分属性中找出信息增益高于平均水平的属性,再从中选择增益率最高的。
  CART 决策树用来评判数据集纯度的指标基尼值则要来得更为纯粹一些:
G i n i ( D ) = ∑ k = 1 m ∑ k ′ ≠ k p k p k ′ = 1 − ∑ k = 1 m p k 2 Gini(D)=\sum_{k=1}^m\sum_{k'\neq k}p_kp_{k'}=1-\sum_{k=1}^m{p_k}^2 Gini(D)=k=1mk=kpkpk=1k=1mpk2 G i n i _ i n d e x ( D , a ) = ∑ v = 1 V ∣ D v ∣ ∣ D ∣ G i n i ( D v ) Gini\_index(D,a)=\sum_{v=1}^V\frac{|D^v|}{|D|}Gini(D^v) Gini_index(D,a)=v=1VDDvGini(Dv)   G i n i ( D ) Gini(D) Gini(D) 直观反映了从数据集 D D D 中随机抽取两个样本,其类别标记不一致的概率。因此最优属性的选择标准就变成了
a ∗ = argmin ⁡ a ∈ A   G i n i _ i n d e x ( D , a ) a_*={\underset {a∈A}{\operatorname {argmin} }}\,Gini\_index(D,a) a=aAargminGini_index(D,a)

  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值