Kmeans算法学习

目录

概述

Kmeans算法原理简介

实战问题

问题分析

读取数据

定义数据结构

读取原始文件

组织数据

Kmeans算法分类

类的数据定义及初始化

计算初始中心点

分类

计算新的中心

KMeans迭代

结果输出


参考:

  1. 全面解析Kmeans聚类(Python) · Issue #42 · aialgorithm/Blog · GitHub
  2. Kmeans++聚类算法原理与实现 - 知乎 (zhihu.com)

概述

本博文通过学习《全面解析Kmeans聚类(Python) · Issue #42 · aialgorithm/Blog · GitHub》中的Kmeans算法以及其中的一个程序,详细描述了Kmean聚类算法的实现细节。通过这个学习,可以更好地理解Kmeans算法。在学习过程中,也大量参考了《Kmeans++聚类算法原理与实现 - 知乎 (zhihu.com)》这篇文章

Kmeans算法原理简介

Kmeans算法主要用于无监督学习中的聚类,即将一堆数据按照其数据特征分类。通过分类,我们有可能发现这些数据中隐含的更深层次的意义,对于分析大量的实际数据是十分有益的。

Kmeans算法的主要思想是:每一个数据都由若干特征组成,这些特征之间存在差异,我们将这些差异定义为一种“距离”。于是,同一类的数据之间,必然距离较近,而不同类的数据之间,其距离较远。通过不断地迭代,不断地将类别划开。

Kmeans算法主要步骤为:

  1. 随机选择 k 个样本作为初始簇类中心(k为超参,代表簇类的个数。可以凭先验知识、验证法确定取值);
  2. 针对数据集中每个样本 计算它到 k 个簇类中心的距离,并将其归属到距离最小的簇类中心所对应的类中;
  3. 针对每个簇类,重新计算它的簇类中心位置;
  4. 重复迭代上面 2 、3 两步操作,直到达到某个终止条件(如迭代次数,簇类中心位置不变等)。

Kmeans算法的缺点在于,起始的k个族类中心点是随机选取,其初始情况可能并不理想。为了解决这一问题,引入了Kmeans++算法。

Kmeans++算法的思想为:在选取初始族类中心点时,只有第一个点是随机选择,其后的点计算剩余样本点和前面已选的中心点之间的距离,找出距离最远的点作为下一个初始族类中心点。初始中心点选取之后,剩下的步骤和Kmeans算法一样。

第二篇参考文章(《Kmeans++聚类算法原理与实现 - 知乎 (zhihu.com)》)中给出了一个计算示例,我觉得非常通俗易懂,如果不理解Kmeans++算法的,建议在这里暂停一下,先看看这篇参考文章中的计算示例。

实战问题

本文剩下的篇幅,将通过第一篇参考文章(《全面解析Kmeans聚类(Python) · Issue #42 · aialgorithm/Blog · GitHub》)中的一个“对不同种类的狗进行分类”的具体问题,来逐步地详细地讲解Kmeans算法。

个人觉得原文中的Python程序不是很好理解,因此我基于原文中Python程序,重新写了一份Python程序。

有如下一份不同种类的狗的一些特征数据,存放于文件“dogs.csv.txt”中,其内容如下:

breed,height (inches),weight (pounds)
Border Collie,20,45
Boston Terrier,16,20
Brittany Spaniel,18,35
Bullmastiff,27,120
Chihuahua,8,8
German Shepherd,25,78
Golden Retriever,23,70
Great Dane,32,160
Portuguese Water Dog,21,50
Standard Poodle,19,65
Yorkshire Terrier,6,7

文件中:

  • 第1行是标题行,其余是数据内容
  • 数据第1列是狗的品种名称,第2列是这个品种的狗的平均身高,第3列是这个品种的狗的平均体重

目标是通过对狗的身高,体重数据,将这些不同品种的狗分类。

问题分析

首先,容易想到的是,这个问题可以分为读取数据(load data)和算法分类这两个阶段。

其次,注意到数据中有3列,但第1列(breed)数据其实并不是Kmeans算法中涉及的“特征”数据,即在计算数据之间的距离时,我们不需要这一列数据。同时,这一列数据显然也不是Kmeans算法中需要标记的种类。这一列数据可以看作是某个具体样本的名称或者代号。因此,在Kmeans算法中,不建议输入这列数据。

但是,当Kmeans分类结束后,在某一类中,我们希望在这个类中出现的就是这一列数据(比如在某一类中,其内容为:'Bullmastiff', 'Great Dane')。这么一看,第1列(breed)数据似乎应该出现在Kmeans算法中。对于这一矛盾,我的理解是,这一列数据不输入给Kmeans算法,即Kmeans算法只负责将具体的一个个特征数据(height, weight)放入某一个分类中,而不用关心这个数据是叫'Bullmastiff',还是叫'Great Dane',以此保持Kmeans算法的通用性;而在算法结束时,在算法外围,根据算法的结果最终处理聚类和第1列(breed)数据之间的关系。我认为这样的处理更加合理。

因此,程序划分为3个阶段:

  1. 读取数据
  2. Kmean算法分类
  3. 根据Kmean算法的分类结果,整理问题的最终结果,并输出

读取数据

定义数据结构

个人喜欢用类或者结构体来表示数据,而不喜欢看起来比较抽象的词典数据。尤其是词典中嵌套词典,这种数据结构写出来的程序很难读懂。因此,我首先定义了以下纯数据类

class Dog:
    def __init__(self, breed, height, weight):
        self.breed = breed
        self.height = height
        self.weight = weight
 
class Dogs:
    def __init__(self, num, dogs_data):
        self.num = num
        self.dogs_data = dogs_data

说明:

  • dogs_data是一个以Dog对象为元素的列表

读取原始文件

先将文件中的数据,读取到一个“表格”中,这里的表格就是一个二维的数据结构,第一维按行划分,第二维按照','划分。

def read_csv(filename, row_offset = 1):
    """
    read csv files, return strings sequences, 
    each line is splitted by ','
    """
    out = []
    with open(filename, 'r') as f:
        data = f.readlines()
    for i in range(row_offset, len(data)):
        line = data[i].replace('\n', '').split(',')
        out.append(line)
    print("{} read, out = {}".format(filename, out))
    return out

说明:

  • 从文件中读取的每一行字符串是包含换行符'\n'的,因此data[i]需要先去掉换行符,即"replace('\n', '')",表示将读取到的换行符替换为空字符串。
  • 对于去掉换行符后的字符串,按','分隔为字串,即"split(',')"。
  • 用c语言的表述,输出的数据是一个字符串类型的二维数组

组织数据

将文件中读取的原始数据(一个字符串类型的二维数组),按照前面定义的数据结构,组织为后面算法需要的数据

def load_dogs(filename):
    """
    read dogs data from csv file
    """
    raw = read_csv(filename)
    num = len(raw)
    dogs_data = []
    for item in raw:
        dog = Dog(item[0], int(item[1]), int(item[2]))
        dogs_data.append(dog)
    dogs = Dogs(num, dogs_data)
    return dogs

说明:

  • 对于第2列和第3列数据,原始数据中是字符串,因此,这里使用了int()将其转化为整数类型。Python的内置类型int, str, float,都可以使用后跟()的方式作类型转换

Kmeans算法分类

Kmeans算法的实现和参考1一样,使用了Python的类。关于Python类的使用,在Python教程学习 (6)中有较为详细的介绍。在使用Python类的时候,需要注意以下几点:

  1. 类的成员变量一般在__init__()函数中显示定义并初始化
  2. 在类的成员(包括函数和变量)中,如果使用了任何类的其它成员(包括函数和变量),都需要加上‘self.’,否则会报错。这点对经常使用C++类的人来说,一开始可能会有点不习惯。
  3. 所有成员函数的第一个参数都是self,并且必须显示地写出来

按照前面Kmeans算法原理的描述,这段程序可以大致分为以下几个步骤:

  1. 初始化,选取k个中心点。这里应该支持传统的Kmeans方式,即‘random’:随机选取,也应该支持Kmeans++的方式,即'kmeans++'。并且默认为'kmeans++'(这里参考了sklearn的KMeans的实现方式,详见sklearn.cluster.KMeans — scikit-learn 1.3.2 documentation)。
  2. 根据当前中心点,基于每个样本数据和中心点的距离,对每个样本数据进行分类。这里的距离,我们采用了和参考1一样的方式,即曼哈顿距离。关于曼哈顿距离的定义,详见参考1。
  3. 根据当前分类结果,重新计算每个类的新的中心点。
  4. 重复步骤2,3,直到新的中心点和上一次的中心点一致为止

注意:本节中所有代码都属于类MyKmeans中

类的数据定义及初始化

首先需要定义类的成员变量,以及初始化函数。实际上,在程序实现过程中,一开始只能从类的输入参数来考虑类的成员,无法考虑到中间参数的成员,输出参数可能也只能考虑到一个最基本的输出。随着程序的实现,类的成员是可以调整的。

下面代码是最终的实现。

class MyKmeans:
    def __init__(self, features, n_clusters, *, init='kmeans++', max_iter=10, tol=0.0001):
        self.data = features
        self.dim = len(features[0])
        self.k = n_clusters
        self.init = init
        self.iters = max_iter
        self.tol = tol
        self.centers = self.init_centers(self.k)
        self.targets = [-1 for i in range(len(self.data))] # sequence corresponding to each item of self.data, with value in {0,1,...,k-1}
        self.distances = [0 for i in range(len(self.data))]

其实,在程序设计的开始,只包括了以下参数:

  • data(需要分类的数据),k(分类个数),init(算法类型,传统kmeans或者kmeans++),iters(最大迭代次数),tol(迭代差异的接受值)。这些是从输入参数的角度考虑的。其中,tol用于结束迭代、实际上没有用到,这个参数是参考的sklearn的KMeans实现设计的。
  • centers(中心点)。既是中间参数,也是输出结果之一
  • targets(每个样本数据的分类结果)。输出结果之一

在开发过程中,增加了以下参数:

  • dim(样本参数的维度)。在本例中即height, weight两个维度。用于中间参数。
  • distances(每个样本距离中心的距离)。输出结果之一。

个人感觉,其实还可以增加一个中间参数:

  • data_num(样本参数的个数)。用于中间参数。接下来的实现中,会看到程序中经常使用len(self.data)。

计算初始中心点

前面提到,支持两种方式获取初始中心点:

  • 'random':从样本数据中随机选择k个、作为中心点
  • 'kmeans++':从样本数据中随机选择1个作为第一个中心点,剩余(k-1)个中心点、选择和前面中心点距离最远的点。

下面看具体的程序:

    def init_centers(self, k):
        # set a interger for seed to make sure each run get the same samples
        random.seed(4)

        # if 'random', choose k samples from data randomly as the centers
        if self.init == 'random':
            centers = [x for x in random.sample(self.data, k)]
        # if 'kmeans++', choose centers based on the distance
        else:
            # 1. choose first center randomly
            centers = []
            centers.append(random.choice(self.data))
            # 2.choose other centers based on the distance between the already chosen centers
            for i in range(1, self.k):
                # for each point in data, get the minimum distance between it and the already chosen centers as the weight
                weights = [self.get_min_dist(x, centers) for x in self.data]

                # choose the data with the max weight, as the next center
                tmp = zip(weights, self.data)
                tmp = sorted(tmp)
                next_center = tmp[len(tmp)-1][1] # the last one is with the max weight
                centers.append(next_center)
        print("At initialization, centers ={}".format(centers))
        return centers

说明:

  • random.seed(4)设置了随机数种子。这样做的目的是为了每次运行程序,得到相同的随机序列,从而输出稳定的结果,便于程序开发和调试
  • 'centers = [x for x in random.sample(self.data, k)]' 这里使用了python的列表推导式,可以简化循环的形式,迅速获取一个新的列表。'random.sample(self.data, k)'表示从self.data的样本数据集中随机选择k个样本。由于前面设置了随机数种子,因此每次运行程序时,随机选择的样本集合是一样的。
  • 在kmeans++分支中,'weights = [self.get_min_dist(x, centers) for x in self.data]' 这里定义了一个函数get_min_dist()用于获取某个样本数据到已有中心点列表centers中的最小距离,这个最小距离会作为选择下一个中心点的权重:找出这个权重中的最大者,对应的点即为下一个中心点,因为这个最大者就是距离已有中心中最远的点(如果不理解这个,可以先看看参考2中的计算示例)。
  • ‘tmp = zip(weights, self.data)’ 这行是绑定了权重和样本数据,便于后面对权重排序时,获取这个权重对应的样本数据。注意权重weights要放在前面,这使得调用sorted()函数时,排序的关键字是权重weights。如果不理解zip()函数的用法,可以参考python手册:内置函数 — Python 3.12.1 文档

下面看看get_min_dist()函数的定义:

    #get the minimum distance between current point and the already chosen centers
    def get_min_dist(self, x, centers):
        min = 999999
        for i, center in enumerate(centers):
            distance = 0
            for j in range(self.dim):
                distance += abs(x[j] - center[j])  # Manhattan Distance
            if distance < min:
                min = distance
        return min

说明:

  • 在for循环中,如果同时想获取循环下标i和每一个循环数据,可以使用类似“for i, center in enumerate(centers):”的形式,十分方便
  • 曼哈顿距离就是每个维度之间的差的绝对值,然后再对所有维度求和

分类

分类的思想很简单,在确定了k个中心点之后,对每一个样本数据,查看其距离哪个中心点最近,那么就认为这个样本属于、由这个中心点确立的一个类。

回看一下前面的get_min_dist()函数,我们发现,这个函数实际上正是在实现上述功能,只是缺少了返回中心点的信息。因此,我们将get_min_dist()函数稍作改造,得到新的函数get_nearest_dist_and_center()

    # for current point x, get the distance between it and the nearest center, and also get the index of the nearest center
    def get_nearest_dist_and_center(self, x, centers):
        min = 999999
        center_id = -1
        for i, center in enumerate(centers):
            distance = 0
            for j in range(self.dim):
                distance += abs(x[j] - center[j])  # Manhattan Distance
            if distance < min:
                min = distance
                center_id = i
        return min, center_id

分类函数调用get_nearest_dist_and_center()函数

    # clustering current data based on current centers based on distances
    def clustering_based_on_distance(self):
        for i, data in enumerate(self.data):
            self.distances[i], self.targets[i] = self.get_nearest_dist_and_center(data, self.centers)
        return None

说明:

  • get_nearest_dist_and_center()返回了多个参数,彼此可以用逗号隔开,python会将其当成一个元组结构
  • 调用返回元组的函数时,可以用多个参数分别对应元组的每个元素,正如clustering_based_on_distance()函数中的方式;也可以用一个元组参数x接收,然后使用x[0], x[1]的方式访问元组的每个元素
  • self.distances[i]表示每个样本距离其最近中心的距离,self.targets[i]是每个样本对应的分类索引

计算新的中心

每个样本,如果以其维度为坐标系,以其各个维度的值为坐标,则可以表示为一个多维空间中的一个点。对于当前分类,每个类中都有一些属于自己类的样本。将这些样本在多维空间中对应的点求几何中心,即每个维度的坐标分别求算数平均,就可以得到一个新的中心点。

例如,我们对数据中的“Bullmastiff,27,120”和“Great Dane,32,160”这两个点求几何中心,为:x1 = (27+32)/2 = 29.5, x2 = (120+160)/2 = 140。于是其几何中心坐标为[29.5, 140]。

self.data[i]中存储了每个样本的坐标,self.targets[i] 中存储了当前分类中每个样本所属的类,利用这两个参数可以求得新的中心。

    # calculate the new centers
    def locate_new_centers(self):
        new_centers = [[0.0 for j in range(self.dim)] for i in range(self.k)]
        for cluster_id in range(self.k):
            # get data belong to current center
            data_in_cluster = [self.data[i] for i in range(len(self.data)) if self.targets[i] == cluster_id]

            # get the average of all data that belong to current center, as the new center
            for j in range(self.dim):
                for data in data_in_cluster:
                    new_centers[cluster_id][j]  += data[j]
                new_centers[cluster_id][j] /= len(data_in_cluster)
            print("new_centers[{}]={}".format(cluster_id, new_centers[cluster_id]))
        return new_centers

说明:

  • ‘[[0.0 for j in range(self.dim)] for i in range(self.k)]’这里使用了列表推导式的嵌套,相当于一个两层循环。其中,内层括号代表内层循环,外层括号代表外层循环。如果对这个知识点不熟悉的话,可以参考我的博文Python教程学习 (3)中"嵌套的列表推导式"这一节。
  • “[self.data[i] for i in range(len(self.data)) if self.targets[i] == cluster_id]”使用了带"if"条件的列表推导式,相当于在for循环里面加了这样一个“if”条件。这一句的作用是将属于某一个类的所有样本数据组成一个新的列表data_in_cluster
  • 在得到了某一个类的所有样本数据列表data_in_cluster之后,对其中所有样本的每个维度取算术平均,即得到了新中心点new_centers[cluster_id][j] 。对应代码应该不难理解。

KMeans迭代

在拥有了分类clustering_based_on_distance()和计算新中心locate_new_centers()之后,我们可以很容易地写出迭代函数

    def kcluster(self):
        #最大迭代次数iters
        for i in range(self.iters):
            self.clustering_based_on_distance()
            new_centers = self.locate_new_centers()
            
            if sorted(self.centers) == sorted(new_centers):
                break
            else:
                self.centers = new_centers
            print("----------------Results for iter %d----------------"%(i+1))
            print("centers: {}".format(self.centers))
            print("targets: {}".format(self.targets))

说明:

  • 容易发现,其中一个结束迭代的条件就是新的中心集合和当前中心集合一致,即sorted(self.centers) == sorted(new_centers)

结果输出

为了使用方便,单独定义一个函数clustering_and_results()用于调用,从读取数据、到Kmeans算法分类这一整个过程的所有类和函数。

def clustering_and_results(cluster_num=3):
    # load data
    dogs = load_dogs(r'ai\dogs.csv.txt')
    #data = [[dogs.dogs_data[i].height, dogs.dogs_data[i].weight] for i in range(len(dogs.dogs_data))]
    data = [[dog.height, dog.weight] for dog in (dogs.dogs_data)]
    # clustering
    model = MyKmeans(data, cluster_num)
    #model = MyKmeans(data, 3, init='random')
    model.kcluster()
    # print results
    for i in range(cluster_num):
        dogs_in_cluster = [dog.breed for dog_id, dog in enumerate(dogs.dogs_data) if model.targets[dog_id] == i]
        print("Breeds in Cluster {}: {}".format(i, dogs_in_cluster))
    for i in range(cluster_num):
        distances_in_cluster = [model.distances[dog_id] for dog_id in range(len(dogs.dogs_data)) if model.targets[dog_id] == i]
        print("Distances in Cluster {}: {}".format(i, distances_in_cluster))
    return None

说明: 

  • ‘data = [[dog.height, dog.weight] for dog in (dogs.dogs_data)]’这句中,我们将每一个dog的身高和体重提取出来,组成一个[身高,体重]的列表,所有dog的[身高,体重]数据,组成一个新的列表数据,这个数据就是KMeans算法输入的特征数据。注意到这个写法比前面的“data = [[dogs.dogs_data[i].height, dogs.dogs_data[i].weight] for i in range(len(dogs.dogs_data))]”更加简洁。
  • 在获取输出结果时,我们同样使用了列表推导式“[dog.breed for dog_id, dog in enumerate(dogs.dogs_data) if model.targets[dog_id] == i]”。如果你认真看了前面的内容,那么这个列表推导式应该不难理解。

前面所有代码均定义在python文件my_kmeans.py中,我们打开一个python对话框,使用如下方式调用程序

>>> import ai.my_kmeans
>>> ai.my_kmeans.clustering_and_results()

在设置kmeans++算法时,得到如下打印:

ai\dogs.csv.txt read, out = [['Border Collie', '20', '45'], ['Boston Terrier', '16', '20'], ['Brittany Spaniel', '18', '35'], ['Bullmastiff', '27', '120'], ['Chihuahua', '8', '8'], ['German Shepherd', '25', '78'], ['Golden Retriever', '23', '70'], ['Great Dane', '32', '160'], ['Portuguese Water Dog', '21', '50'], ['Standard Poodle', '19', '65'], ['Yorkshire Terrier', '6', '7']]
At initialization, centers =[[27, 120], [6, 7], [19, 65]]
new_centers[0]=[29.5, 140.0]
new_centers[1]=[10.0, 11.666666666666666]
new_centers[2]=[21.0, 57.166666666666664]
----------------Results for iter 1----------------
centers: [[29.5, 140.0], [10.0, 11.666666666666666], [21.0, 57.166666666666664]]
targets: [2, 1, 2, 0, 1, 2, 2, 0, 2, 2, 1]
new_centers[0]=[29.5, 140.0]
new_centers[1]=[10.0, 11.666666666666666]
new_centers[2]=[21.0, 57.166666666666664]
Breeds in Cluster 0: ['Bullmastiff', 'Great Dane']
Breeds in Cluster 1: ['Boston Terrier', 'Chihuahua', 'Yorkshire Terrier']
Breeds in Cluster 2: ['Border Collie', 'Brittany Spaniel', 'German Shepherd', 'Golden Retriever', 'Portuguese Water Dog', 'Standard Poodle']
Distances in Cluster 0: [22.5, 22.5]
Distances in Cluster 1: [14.333333333333334, 5.666666666666666, 8.666666666666666]
Distances in Cluster 2: [13.166666666666664, 25.166666666666664, 24.833333333333336, 14.833333333333336, 7.166666666666664, 9.833333333333336]

可以看到,算法只迭代了一次,得到分类为:

Breeds in Cluster 0: ['Bullmastiff', 'Great Dane']
Breeds in Cluster 1: ['Boston Terrier', 'Chihuahua', 'Yorkshire Terrier']
Breeds in Cluster 2: ['Border Collie', 'Brittany Spaniel', 'German Shepherd', 'Golden Retriever', 'Portuguese Water Dog', 'Standard Poodle']

如果使用'random'方式,修改函数clustering_and_results(),使用"model = MyKmeans(data, 3, init='random')",得到如下输出

ai\dogs.csv.txt read, out = [['Border Collie', '20', '45'], ['Boston Terrier', '16', '20'], ['Brittany Spaniel', '18', '35'], ['Bullmastiff', '27', '120'], ['Chihuahua', '8', '8'], ['German Shepherd', '25', '78'], ['Golden Retriever', '23', '70'], ['Great Dane', '32', '160'], ['Portuguese Water Dog', '21', '50'], ['Standard Poodle', '19', '65'], ['Yorkshire Terrier', '6', '7']]
At initialization, centers =[[27, 120], [8, 8], [16, 20]]
new_centers[0]=[26.75, 107.0]
new_centers[1]=[7.0, 7.5]
new_centers[2]=[18.8, 43.0]
----------------Results for iter 1----------------
centers: [[26.75, 107.0], [7.0, 7.5], [18.8, 43.0]]
targets: [2, 2, 2, 0, 1, 0, 0, 0, 2, 2, 1]
new_centers[0]=[28.0, 119.33333333333333]
new_centers[1]=[10.0, 11.666666666666666]
new_centers[2]=[20.2, 53.0]
----------------Results for iter 2----------------
centers: [[28.0, 119.33333333333333], [10.0, 11.666666666666666], [20.2, 53.0]]
targets: [2, 1, 2, 0, 1, 0, 2, 0, 2, 2, 1]
new_centers[0]=[29.5, 140.0]
new_centers[1]=[10.0, 11.666666666666666]
new_centers[2]=[21.0, 57.166666666666664]
----------------Results for iter 3----------------
centers: [[29.5, 140.0], [10.0, 11.666666666666666], [21.0, 57.166666666666664]]
targets: [2, 1, 2, 0, 1, 2, 2, 0, 2, 2, 1]
new_centers[0]=[29.5, 140.0]
new_centers[1]=[10.0, 11.666666666666666]
new_centers[2]=[21.0, 57.166666666666664]
Breeds in Cluster 0: ['Bullmastiff', 'Great Dane']
Breeds in Cluster 1: ['Boston Terrier', 'Chihuahua', 'Yorkshire Terrier']
Breeds in Cluster 2: ['Border Collie', 'Brittany Spaniel', 'German Shepherd', 'Golden Retriever', 'Portuguese Water Dog', 'Standard Poodle']
Distances in Cluster 0: [22.5, 22.5]
Distances in Cluster 1: [14.333333333333334, 5.666666666666666, 8.666666666666666]
Distances in Cluster 2: [13.166666666666664, 25.166666666666664, 24.833333333333336, 14.833333333333336, 7.166666666666664, 9.833333333333336]

可以看到,算法迭代了3次,效率比Kmeans++要低一些。但最终分类结果时一样的。

  • 31
    点赞
  • 36
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值