pytorch搭建分类网络并进行训练和测试

前言

本篇主要是讲整个的流程,如果想了解本篇涉及所函数详细的用法可自行查阅.
另外这只是学习笔记和一些心得体会, 里面会有不对之处,请告诉我吧

代码下载地址:https://github.com/hanlinshi/pytorch-for-classfication
图像分类六个步骤:

  1. 准备数据;
  2. 加载数据;
  3. 定义网络结构;
  4. 训练模型;
  5. 测试模型;
  6. 图像预测(模型应用);

第一步骤和第二步骤流程图:

1.1
2.1.1和2.1.2
2.1.1和2.1.3
1.2
2.1.1和1.3
2.2
自己的图像数据
按目录存放
Dataset类
公共数据集
数据迭代器
  • 流程图链接线上的数字表示实现这一流程的章节序号.

1. 准备数据

可以用自己准备的图像数据, 或者用公共数据集(本文以CIFAR10为例).
如果用公共数据集有两种方式:

  • 自己下载解压,这流程和用自己的图像数据差不多;
  • 直接用torchvision提供的datasets工具包完成数据准备工作;

1.1 准备自己的数据

步骤: 图像归类 —> 划分数据集

1) 图像归类

  • 把收集的图像按照类别放入不同的文件夹中;
  • 文件夹以类别标签命名.
  • 每个类别的图像数量要差不多一样多比较好,保持数据平衡.如果某个类别的图像数量比较少,该类别的预测效果很可能会不好.
  • 图像目录如图:
    图像分类目录结构

2) 划分数据集
脚本: image2train_val_test.py
将图像数据分成训练集,验证集,测试集.(train, validation, test)

  • 训练集: 训练网络权重的数据;
  • 验证集: 训练时,用来调整超参数以及查看模型什么时候停止比较好,可以理解为训练过程中的测试数据集;
  • 测试集: 训练完成,测试模型的泛化能力;

我习惯按照7:1:2比例对数据集进行划分, 也有很多按照8:1:1进行划分.看自己的考虑吧.

1.2 自己下载Cifar10数据集

步骤: 下载数据集 —> 解压数据集 —> 提取图片 —> 划分数据集

1)下载数据

  • 下载好Cifar10数据 cifar-10-python.tar.gz,
  • 放到目录root/Data 下.

2) 解压数据

解压出来8个文件:

data_batch_1 ~ data_batch_5, 这5个文件是训练数据, 各包含1万个样本

test_batch  这个是测试数据 包含1万个样本

batches.meta  是十个标签名称

readme.html  是官网链接地址

3) 提取图片
脚本: cifar10_to_png.py
步骤: 构建存放图片的文件目录 —> 解析(读取)数据文件 —> 按照图片标签保存图片

  • 构建存放图片的文件目录

    • 一个总目录, 下面分为十个标签命名的目录
    • cifar10的10个标签按照顺序依次是:
      “airplane”, “automobile”, “bird”, “cat”, “deer”, “dog”, “frog”, “horse”, “ship”, “truck”
      飞机, 汽车, 鸟, 猫, 鹿, 狗, 青蛙, 马, 船, 货车
  • 解析(读取)数据文件

    • cifar10的官网简介: https://www.cs.toronto.edu/~kriz/cifar.html
    • CIFAR-10数据集包括10个类别的60000个32x32彩色图像,每个类别有6000个图像。有5万张训练图像和1万张测试图像。数据集分为五个训练批和一个测试批,每个批有10000张图像。测试批包含从每个类中随机选择的1000个图像。
    • data_batch_1~data_batch_5, test_batch 可以用pickle读取这些文件.读取的文件将返回一个dict对象,有data,和labels两个key. data是图像, labels是标签的编号0~9.
    • batches.meta 用pickle读取返回一个dict, 他有一个key叫做label_names, 是一个列表,读取方式:
     label_names[0] == "airplane"
     label_names[1]  == "automobile"
    

我这里将训练数据和测试数据都解压保存一起了.

  • 按照图片标签保存图片
    数据文件解压出来的dict,保存的时候,要注意:
    • resize改变原来数据形状,
      dict[“data”]是一个list.这个数组的每一行存储了3232大小的彩色图像(3232*3通道=3072)。
    • transpose变换一下顺序.
      dict[“data”]数组的每一行前1024个数是red通道,然后分别是green,blue.就是将RGB改成GBR. 需要改变通道顺序的原因是下一步用opencv保存图片,而opencv保存图片的顺序是GBR.
    • 最后用opencv保存图片.

4) 划分数据集
脚本(同1.1的2): image2train_val_test.py

1.3 使用torchvision下载CIFAR10

使用torchvision提供的datasets工具包datasets.CIFAR10可以直接下载该公共数据集.并返回返回Dataset对象 这种方式下载需要网好.

  • Dataset对象是pytorch通用的数据集对象(更详细的可自己查pytroch的Dataset类)
  • 虽然就一行代码, 但这里面包含了下载图片图片预处理(关于图片预处理见2.1.1章节)
# 训练集
trainset = torchvision.datasets.CIFAR10(
                    root='./data', 
                    train=True,
                    download=True, 
                    transform=transform)
# 测试集
testset = torchvision.datasets.CIFAR10(
                     root='./data', 
                     train=False,
                     download=True, 
                     transform=transform)

2 数据加载

步骤: 从图片数据到pytorch的Dataset对象 --> 从pytorch的Dataset对象到pytorch的数据迭代器

数据加载是指,将图片数据转换成模型输入数据.

第一步,从图片数据到pytorch的Dataset对象有两种方式,

  • 用torchvision提供的ImageFolder函数. 该函数也包含了图片预处理过程.
  • 继承Dataset这个基类,写方法实现.该步骤同样会进行图片的预处理.

第二步,使用pytorch的工具函数DataLoader.实现从pytorch的Dataset对象到pytorch的数据迭代器

2.1 从图片数据到pytorch的Dataset对象

2.1.1 数据预处理

可以看到不管哪种方式都少不了图片预处理.图片预处理可以使得图片更加多样性,训练出来的模型泛化能力也会更强.

torchvision提供的transforms有22钟预处理图片方式,可满足平常所需.(transforms详细请先自行查阅)或者我的笔记TORCHVISION.TRANSFORMS的图像预处理

transform = transforms.Compose(
        [transforms.RandomResizedCrop(224),
         transforms.RandomHorizontalFlip(),
         transforms.ToTensor()])

注意这里是下载好图片以后,在训练调用数据时,会按transform已经指定好的顺序依次预处理操作, 然后送给网络进行训练,并不是下载图片时进行预处理,然后保存预处理后的图片,也不会训练时保存预处理后的图片,总之不会保存预处理后的图片,除非你自己写代码保存,

2.1.2 用torchvision提供的函数

脚本: loaddata.py
torchvision提供的ImageFolder函数能够以目录名为标签(就是第一节准备的图片目录结构)来对数据集做划分. (ImageFolder详细请先自行查阅)

trainset = torchvision.datasets.ImageFolder(
                root="root folder path",
                transform=transform,  
                target_transform=None, 
                loader=default_loader)

2.1.3 自己写方法实现

脚本:mydataset.py

自己写的方法是通过继承Dataset这个基类实现的.

可以参考ImageFolde的方式来实现.

  1. 规定好图片的存放形式,
  2. 输入: 图片的根目录,以及图像预处理.
  3. 重写Dataset这个基类的__getitem__(self, index) 和__len__(self)方法.getitem(self, index) 是根据索引index返回图像和标签, 这个index通常是list的index. len(self)返回样本数量.

具体实现参见脚本代码.

2.2 从pytorch的Dataset对象到pytorch的数据迭代器

torch.utils.data.DataLoader 构建可迭代的数据装载器. 加载数据的时候使用mini-batch可以进行多线程并行处理,这样可以加快数据加载速度. (DataLoader详细请先自行查阅)

data_loader = torch.utils.data.DataLoader(
                    data, 
                    batch_size=batch_size, 
                    shuffle=True, 
                    drop_last=False, 
                    num_workers=4)

3 搭建网络

3.1 搭建网络

脚本:mysimplenet.py

搭建网络的思路相对简单一些:

  • 继承torch的nn.Module.
  • 在__init__初始化函数体中定义好神经节点(定义“组件”)
  • 重写forward函数,将定义好的神经节点链接起来,形成网络.神经节点可以重复使用.(用“组件”搭建网络)

可以先看简单的网络脚本:mysimplenet.py,

import torch.nn as nn
""" 定义网络 """

class Batch_Net(nn.Module):
    """
    定义了一个简单的三层全连接神经网络,每一层都是线性的 nn.Linear
    加快收敛速度的方法--批标准化 nn.BatchNorm1d
    前两层,在每层的输出部分添加了激活函数 nn.ReLU(True)  
    """
    def __init__(self, in_dim, n_hidden_1, n_hidden_2, out_dim):
        super(Batch_Net, self).__init__()
        self.layer1 = nn.Sequential(nn.Linear(in_dim, n_hidden_1), nn.BatchNorm1d(n_hidden_1), nn.ReLU(True))
        self.layer2 = nn.Sequential(nn.Linear(n_hidden_1, n_hidden_2), nn.BatchNorm1d(n_hidden_2), nn.ReLU(True))
        self.layer3 = nn.Sequential(nn.Linear(n_hidden_2, out_dim))
 
    def forward(self, x):
        x = self.layer1(x)
        x = self.layer2(x)
        x = self.layer3(x)
        return x

PS: 想深入理解神经网络参考【深度学习】神经网络入门(最通俗的理解神经网络)

3.2 网络初始化

神经网络的训练过程中的参数学习是基于梯度下降法进行优化的。梯度下降法需要在开始训练时给各个节点的权重和偏置赋一个初始值。这个初始值的选取十分关键。参数的初始化关系到网络能否训练出好的结果或者是以多快的速度收敛。

初始化方法总共有三种:

  • 默认初始化;
  • 自定义初始化,用pytorch自带的初始化函数进行初始化;
  • Finetune初始化,用预训练模型进行初始化,是迁移学习的一种手段。

3.2.1 默认初始化

在创建网络实例过程中,就会进行默认初始化。一般是均值分布采样初始化。

3.2.2 自定义初始化

可以使用pytorch提供的初始化函数进行初始化(初始化函数在 torch.nn.init 中给出,自行查阅或者参见我的笔记TORCH.NN.INIT)。
具体初始化流程:遍历模型的每一层,判断各层属于什么类型, 例如, 是否是 nn.Conv2d、nn.BatchNorm2d、nn.Linear 等,然后根据不同类型的层,设定不同的权值初始化方法,例如,Xavier,kaiming,normal_,uniform_等。

kaiming 也称之为 MSRA 初始化,当年何恺明还在微软亚洲研究院,因而得名

初始化例子

from torch.nn import init
# 不同类型的层不同初始化
def weigth_init(net):
    '''初始化网络'''
    for m in net.modules():
        if isinstance(m, nn.Conv2d):
            init.xavier_uniform_(m.weight.data)    
            init.constant_(m.bias.data, 0)
        elif isinstance(m, nn.BatchNorm2d):
            init.constant_(m.weight.data, 1)
            init.constant_(m.bias.data, 0)
        elif isinstance(m, nn.Linear):
            init.normal_(m.weight.data, std=1e-3)
            init.constant_(m.bias.data, 0)
# 初始化调用一
model = Net() # 实例化一个网络
model.apply(weigth_init)  # 初始化
# 初始化调用二
net = Net() # 实例化一个网络
weigth_init(net)

3.2.3 Finetune初始化

预训练模型在线下载,下模型后的地址是:~/.cache/torch/hub/checkpoints
预训练模型下载地址:
model_urls = {
‘vgg11’: ‘https://download.pytorch.org/models/vgg11-bbd30ac9.pth’,
‘vgg13’: ‘https://download.pytorch.org/models/vgg13-c768596a.pth’,
‘vgg16’: ‘https://download.pytorch.org/models/vgg16-397923af.pth’,
‘vgg19’: ‘https://download.pytorch.org/models/vgg19-dcbb9e9d.pth’,
‘vgg11_bn’: ‘https://download.pytorch.org/models/vgg11_bn-6002323d.pth’,
‘vgg13_bn’: ‘https://download.pytorch.org/models/vgg13_bn-abd245e5.pth’,
‘vgg16_bn’: ‘https://download.pytorch.org/models/vgg16_bn-6c64b313.pth’,
‘vgg19_bn’: ‘https://download.pytorch.org/models/vgg19_bn-c79401a0.pth’,
}
模型微调(Fine Tune)给一个预训练模型(pre-trained model),基于这个模型进行微调,相对从头开始训练(Training a model from scratch)节省了大量的计算资源和计算时间,提高了计算效率,甚至提高了准确率。Fine Tune是迁移学习的一种手段。
详细讲解可以参见CNN入门讲解:什么是微调(Fine Tune)?

网络模型可以分为三段:第一段为浅层卷积神经网络,提取基础特征,如边缘,轮廓等;第二段为深层卷积神经网络,提取抽象特征,如整个脸型;第三段为最后几层,一般为全连接层,根据特征组合进行评分分类。所以总的来说Fine Tune有三种调整方式:

  • 自己的数据量少,但是与预训练模型使用的数据集相似度非常高。这种情况,我们只修改模型的最后几层或者最终的softmax图层的输出类别;
  • 数据量少,相似度低。因为相似度低,所以根据新数据集训练深层网络意义大一些。故冻结第一段的浅层卷积神经网络,训练后面几段;
  • 数据量大,相似度高。这种情况是最有利的情况,这种时候我们可以用把预训练模型作为初始权重进行微调就可以了,所谓微调就是学习率较小的迭代训练。训练的时候设置学习率有两种思路,一种是所有的层都赋一个很小的学习率进行学习。另外一种思路就是前面的层设置较小的学习率,后面的层设置稍大的学习率。

还有一种情况,就是数据量大,但是相似度低。这个时候预训练模型没有什么特别意义。可以选择上面说的默认初始化或者自定义初始化网络。然后从头训练(Training a model from scatch)

这里面就涉及到三个具体的代码实现问题:

  • 拿到预训练模型如何初始化;
  • 如何冻结指定层的神经网络,训练指定层的神经网络;
  • 如何为不同层设置不同的学习率;

参考博客:pytorch 使用预训练模型如resnet、vgg等并修改部分结构
对于第一个问题:
下载好预训练模型,将预训练模型放入默认的模型下载目录:~/.cache/torch/hub/checkpoints中
下列代码就可以获得已经是预训练模型的网络了.

net = torchvision.models.vgg19_bn(pretrained=True)

如果没有预先下载好预训练模型,直接运行这个代码是可以直接下载预训练模型的。但是因为国内的网络原因,还是先手动下载预训练模型,放入默认下载目录下。这样会更快速一些。

使用这行代码我们都不需要自己搭建网络模型了。

我认为这里初始化有两个思路:
一个是自己搭建网络,然后将预训练模型的权重某些层赋值过来。但是这种操作应该比较复杂,我还没有看到相关的代码。
另外一个思路就是获得预训练模型网络以后,改部分层,通常是后面分类层,这种操作的代码比较多。

如何修改预训练模型网络的最后几层?根据层的名字拿到层,然后在重新赋值即可。如果对网络层不熟悉,可以先通过print()函数来查看层的名称。

for name, parameters in net.named_parameters():
    print(name, ':', parameters.size())
# 以下是后面几层print的结果
 ...
features.49.weight : torch.Size([512, 512, 3, 3])
features.49.bias : torch.Size([512])
features.50.weight : torch.Size([512])
features.50.bias : torch.Size([512])
classifier.0.weight : torch.Size([4096, 25088])
classifier.0.bias : torch.Size([4096])
classifier.3.weight : torch.Size([4096, 4096])
classifier.3.bias : torch.Size([4096])
classifier.6.weight : torch.Size([1000, 4096])
classifier.6.bias : torch.Size([1000])

修改方式为,这里10是将默认的1000个分类改成了10个分类

net.classifier._modules['6'] = nn.Sequential(nn.Linear(4096, 10), nn.Softmax(dim=1))

对于第二个问题,如何冻结指定层的神经网络,训练指定层的神经网络
比如可以对不是分类层的其他层冻结起来。指定层的requires_grad属性为False就可以了。没有为False的层在训练代码中将得到训练更新。如下

# 冻结特征层前面30层,特征层后20层以及分类层进行训练
# 这里冻结层数可以根据自己需求更改
param_group = []
learning_rate = 1e-3
for name, parameters in net.named_parameters():
    # 获取层的数量
    layersid = int(name.split(".")[1])
    if not name.__contains__('classifier') and layersid <= 30:
        parameters.requires_grad = False
        param_group += [{'params': parameters, 'lr': learning_rate}]

对于第三个问题,如何为不同层设置不同的学习率
也同第二个问题一样处理,如下:

param_group = []
learning_rate = 1e-6
for name, parameters in net.named_parameters():
    if not name.__contains__('classifier'):
        param_group += [{'params': parameters, 'lr': learning_rate}]
    else:
        param_group += [{'params': parameters, 'lr': learning_rate*1000}]

这三个问题清楚以后,只要组合应用这三种方式,就可以写出前面说的三种fine-tune的调整方式。

为了不混淆,将五种不同的初始化,单独写出训练脚本:
1 train_default_init.py: 默认初始化训练脚本。
2 train_custom_init.py: 自定义初始化训练脚本。
3 train_finetune_classifier.py: fine tune更改分类层训练脚本。
4 train_finetune_deep.py: fine tune冻结浅层卷积神经网络训练脚本。
5 train_finetune_whole.py: fine tune训练整个神经网络,但是设置不同的学习率训练脚本。

4 训练模型

脚本: train.py
现在已经准备好数据集,网络也搭建好了. 现在要做的事情就是确定权重,也就是训练模型,

a @ x = y a@x=y a@x=y进行类比:

  • x x x表示图像, y y y表示该图像的分类. ( x , y ) (x,y) (x,y)就是数据集;
  • a a a表示权重.一个计算通常是两个数的某种操作,图像表示是一个数,而权重则是另外一个数;
  • @ @ @表示网络,也就是权重和图像之前的操作,规定好了计算方法,加减乘除,以及计算步骤,先做哪一步,再做哪一步;
  • 如果有了一张图像, 并且知道了权重,也知道了网络. 我们就可以对图像进行一系列的计算,然后得到 y y y这个分类,这就是推理预测;
  • 而现在是根据(x,y)数据集来反推 a a a. 就是训练模型.做法先给 a a a赋个初始值 a ′ a' a, 然后计算 a ′ @ x a'@x a@x 得到 y ′ y' y, 再根据预测值 y ′ y' y和真实值 y y y之间的差距,最后根据差距来调整 a ′ a' a. 反复这个过程就是训练模型;
  • 计算预测值 y ′ y' y和真实值 y y y之间的差距的函数就是损失函数(Loss Function);
  • 根据损失来调整 a ′ a' a.就是优化器所做的工作.

训练流程图:

图像
预测值
学习率
真值
反向梯度
新模型
取得一批
数据
输入网络
损失函数
学习率
调整策略
优化器
更新权重
迭代完成
结束
保存模型

从流程图上看,训练之前我们需求准备六样东西:

  • 1 数据
  • 2 网络(初始化了的网络)
  • 3 损失函数
  • 4 学习率调整策略
  • 5 优化器
  • 6 保存模型

数据和网络已经准备好了.

4.1 损失函数

pytorch提供了17种损失函数.可先自行查阅.
步骤:在迭代循环外定义损失函数 --> 循环计算损失 -->循环根据损失计算反向梯度

  • 定义损失函数
import torch.nn as nn
# 交叉熵损失函数
celoss = nn.CrossEntropyLoss()
  • 计算损失
# 计算损失值,outputs预测值,labels真值
loss = celoss(outputs, labels)
  • 计算方向梯度
 # loss反向传播 计算反向梯度
loss.backward()

4.2 学习率的调整

合理的学习率可以使优化器快速收敛。一般在训练初期给予较大的学习率,随着训练的进行,学习率逐渐减小。学习率什么时候减小,减小多少,这就涉及到学习率调整方法。
PyTorch 中提供了六种方法供大家使用,.可先自行查阅.

4.3 优化器

PyTorch 提供的十种优化器,有常见的 SGD、ASGD、Rprop、RMSprop、Adam 等等
步骤:在迭代循环外定义优化器 -->循环梯度置零 --> 循环用优化器调整权重

  • 在迭代循环外定义优化器
import torch.optim as optim
# 优化器  lr学习率为0.001 momentum动量为0.9
optimizer = optim.SGD(net.parameters(), lr=0.001, momentum=0.9)
  • 循环梯度置零
# 梯度置零,因为反向传播过程中梯度会累加上一次循环的梯度
optimizer.zero_grad()    
  • 循环用优化器调整权重
# 利用反向梯度 参数更新 
optimizer.step()                   

4.4 保存模型

简单的保存与加载方法有两种方式:

  • 保存整个网络
import torch
# 保存
torch.save(net, PATH) 
# 加载
model_dict=torch.load(PATH)
  • 保存网络中的参数, 速度快,占空间少
import torch 
# 保存
torch.save(net.state_dict(),PATH)
# 加载 model是初始化的网络
model_dict=model.load_state_dict(torch.load(PATH))

4.5 训练过程中的可视化

使用 TensorBoardX可对对神经网络进行统计可视化.

5 测试

测试与训练的流程大体一致,有区别的地方:

  • 训练要向后计算梯度,测试不需要
  • 训练每张多次向前和向后计算,测试每张图片计算一次向前计算;
  • 测试在数据预处理的时候不需要随机剪裁;
  • 测试需要判断对错;

测试流程图:

模型
数据
完成
设置计算设备
向前计算预测值
加载模型
加载数据
取一批数据
累计
测对的数量
准确情况

6 预测(应用推理)

预测部分比较简单,也是最终的目的。
预测流程图如下:

模型
数据
结果
设置计算设备
计算
加载模型
读取图像
图像预处理
结果翻译成
标签名称
打印标签名称

本篇只是大体梳理了一下整个流程。具体细节会慢慢补齐。

好的,下面是一个完整的示例,包括负载预处理、模型训练测试过程。 ```python import torch import torch.nn as nn import numpy as np import pandas as pd from sklearn.preprocessing import StandardScaler from sklearn.model_selection import train_test_split # 加载数据 data = pd.read_csv('load_data.csv') X = data.iloc[:, :-1].values y = data.iloc[:, -1].values # 数据预处理 scaler = StandardScaler() X = scaler.fit_transform(X) # 划分数据集 X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42) # 定义DBN网络 class DBN(nn.Module): def __init__(self, num_features): super(DBN, self).__init__() self.rbm1 = nn.Sequential( nn.Linear(num_features, 256), nn.ReLU(), nn.Linear(256, 128), nn.ReLU(), nn.Linear(128, 64), nn.ReLU() ) self.rbm2 = nn.Sequential( nn.Linear(64, 128), nn.ReLU(), nn.Linear(128, 256), nn.ReLU(), nn.Linear(256, num_features), nn.ReLU() ) def forward(self, x): x = self.rbm1(x) x = self.rbm2(x) return x # 训练模型 num_features = X_train.shape[1] dbn = DBN(num_features) criterion = nn.MSELoss() optimizer = torch.optim.Adam(dbn.parameters(), lr=0.001) for epoch in range(100): inputs = torch.tensor(X_train, dtype=torch.float) targets = torch.tensor(y_train, dtype=torch.float) optimizer.zero_grad() outputs = dbn(inputs) loss = criterion(outputs, targets) loss.backward() optimizer.step() if epoch % 10 == 0: print('Epoch [{}/{}], Loss: {:.4f}'.format(epoch+1, 100, loss.item())) # 测试模型 with torch.no_grad(): inputs = torch.tensor(X_test, dtype=torch.float) targets = torch.tensor(y_test, dtype=torch.float) outputs = dbn(inputs) loss = criterion(outputs, targets) print('Test Loss: {:.4f}'.format(loss.item())) ``` 在这个例子中,我们首先加载负载数据,然后使用`StandardScaler`对数据进行标准化处理。然后,我们使用`train_test_split`将数据集划分为训练集和测试集。 接下来,我们定义了一个DBN类,其中包含两个RBM层。我们使用MSE损失函数和Adam优化器来训练模型,使用PyTorch自带的优化器和损失函数。 在训练过程中,我们将输入和目标转换为PyTorch张量,并使用`optimizer.zero_grad()`清除所有梯度。我们计算输出和目标之间的损失,然后使用`backward()`方法计算所有梯度并使用`optimizer.step()`来更新模型参数。 在测试过程中,我们使用`with torch.no_grad()`上下文管理器来禁用梯度计算,并计算模型在测试集上的损失。 请注意,这只是一个简单的示例,你可能需要根据自己的数据和需求进行更改。
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值