从pytorch神经网络入门到参数冻结与模型保存

1 从pytorch入门到参数冻结

1.1 python基础

1.1.1 super()用法

以下是 super() 方法的语法:
super(type[, object-or-type])
参数:

  • type – 类。
  • object-or-type – 类,一般是 self

Python3.x 和 Python2.x 的一个区别是: Python 3 可以使用直接使用 super().xxx 代替 super(Class, self).xxx 
注:这里的class是子类自己。

1.1.2 super().init()用法

何时要使用super.init():
当需要继承父类构造函数中的内容,且子类需要在父类的基础上补充时,使用super().init()方法。

1.2 pytorch深度学习基础

参考教程:PyTorch深度学习:60分钟入门(Translation)

1.2.1 训练过程

一个典型的神经网络的训练过程是这样的:

  • 定义一个有着可学习的参数(或者权重)的神经网络

  • 对着一个输入的数据集进行迭代:

  • 用神经网络对输入进行处理

  • 计算代价值 (对输出值的修正到底有多少)

  • 将梯度传播回神经网络的参数中

  • 更新网络中的权重

  • 通常使用简单的更新规则: weight = weight + learning_rate * gradient

1.2.2 定义神经网络

import torch.nn as nn
import torch.nn.functional as F

class Net(nn.Module):
    def __init__(self):
        super(Net, self).__init__()
        self.conv1 = nn.Conv2d(1, 6, 5) # 1 input image channel, 6 output channels, 5x5 square convolution kernel
        self.conv2 = nn.Conv2d(6, 16, 5)
        self.fc1   = nn.Linear(16*5*5, 120) # an affine operation: y = Wx + b
        self.fc2   = nn.Linear(120, 84)
        self.fc3   = nn.Linear(84, 10)

    def forward(self, x):
        x = F.max_pool2d(F.relu(self.conv1(x)), (2, 2)) # Max pooling over a (2, 2) window
        x = F.max_pool2d(F.relu(self.conv2(x)), 2) # If the size is a square you can only specify a single number
        x = x.view(-1, self.num_flat_features(x))
        x = F.relu(self.fc1(x))
        x = F.relu(self.fc2(x))
        x = self.fc3(x)
        return x
    
    def num_flat_features(self, x):
        size = x.size()[1:] # all dimensions except the batch dimension
        num_features = 1
        for s in size:
            num_features *= s
        return num_features

net = Net()
net
'''神经网络的输出结果是这样的
Net (
  (conv1): Conv2d(1, 6, kernel_size=(5, 5), stride=(1, 1))
  (conv2): Conv2d(6, 16, kernel_size=(5, 5), stride=(1, 1))
  (fc1): Linear (400 -> 120)
  (fc2): Linear (120 -> 84)
  (fc3): Linear (84 -> 10)
)
'''

仅仅需要定义一个forward函数就可以了,backward会自动地生成。
你可以在forward函数中使用所有的Tensor中的操作。

模型中可学习的参数会由net.parameters()返回。

params = list(net.parameters())
print(len(params))
print(params[0].size()) # conv1's .weight

input = Variable(torch.randn(1, 1, 32, 32))
out = net(input)
'''out 的输出结果如下
Variable containing:
-0.0158 -0.0682 -0.1239 -0.0136 -0.0645  0.0107 -0.0230 -0.0085  0.1172 -0.0393
[torch.FloatTensor of size 1x10]
'''

net.zero_grad() # 对所有的参数的梯度缓冲区进行归零
out.backward(torch.randn(1, 10)) # 使用随机的梯度进行反向传播

1.2.3 代价函数计算

一个简单的代价函数:nn.MSELoss计算输入和目标之间的均方误差。
举个例子:

output = net(input)
target = Variable(torch.range(1, 10))  # a dummy target, for example
criterion = nn.MSELoss()
loss = criterion(output, target)
'''loss的值如下
Variable containing:
 38.5849
[torch.FloatTensor of size 1]
'''

现在,如果你跟随loss从后往前看,使用.creator属性你可以看到这样的一个计算流程图:

input -> conv2d -> relu -> maxpool2d -> conv2d -> relu -> maxpool2d  
      -> view -> linear -> relu -> linear -> relu -> linear 
      -> MSELoss
      -> loss

因此当我们调用loss.backward()时整个图通过代价来进行区分,图中所有的变量都会以.grad来累积梯度。

1.2.4 反向传播更新权重

最简单的更新的规则是随机梯度下降法(SGD):
weight = weight - learning_rate * gradient

我们可以用简单的python来表示:

learning_rate = 0.01
for f in net.parameters():
    f.data.sub_(f.grad.data * learning_rate)

然而在你使用神经网络的时候你想要使用不同种类的方法诸如:SGD, Nesterov-SGD, Adam, RMSProp, etc.

我们构建了一个小的包torch.optim来实现这个功能,其中包含着所有的这些方法。 用起来也非常简单:

import torch.optim as optim
# create your optimizer
optimizer = optim.SGD(net.parameters(), lr = 0.01)

# in your training loop:
optimizer.zero_grad() # zero the gradient buffers
output = net(input)
loss = criterion(output, target)
loss.backward()
optimizer.step() # Does the update

1.2.5 loss.backward()和optimizer.step()

理解Pytorch的loss.backward()和optimizer.step()
loss.backward()将loss计算进行反向传播,计算出所有的梯度,然后存储下来,供后面使用。
optimizer.step()使用反向传播的梯度,对权重值进行更新。

1.3 模型保存与加载

参考博客: Pytorch 保存模型与加载模型

1.3.1 保存模型与加载

  1. 简单的保存与加载方法:

保存整个网络(没有参数)这两行一起写

torch.save(net, PATH)

保存网络中的参数, 速度快,占空间少

torch.save(net.state_dict(),PATH)
#--------------------------------------------------
#针对上面一般的保存方法,加载的方法分别是:
model_dict=torch.load(PATH)

# 保存网络中的参数, 速度快,占空间少

model_dict=model.load_state_dict(torch.load(PATH))

2. 保存更多的信息:
	```python
	torch.save({'epoch': epochID + 1, 
		'state_dict': model.state_dict(), 'best_loss': lossMIN,
        'optimizer': optimizer.state_dict(),'alpha': loss.alpha, 
        'gamma': loss.gamma},
        checkpoint_path + '/m-' + launchTimestamp
         + '-' + str("%.4f" % lossMIN) + '.pth.tar')
	```
	
	以上包含的信息有,epochID, state_dict, min loss, optimizer, 自定义损失函数的两个参数;格式以字典的格式存储。
	加载的方式:
```python
	def load_checkpoint(model, checkpoint_PATH, optimizer):
		if checkpoint != None:
			model_CKPT = torch.load(checkpoint_PATH)
			model.load_state_dict(model_CKPT['state_dict'])
			print('loading checkpoint!')
			optimizer.load_state_dict(model_CKPT['optimizer'])
		return model, optimizer

此处再参考:
pytorch 保存和加载 Checkpoint 模型,实现断点训练

 #保存
# 模型类必须在此之前被定义
model = torch.load(PATH)
model.eval()
torch.save({
'epoch': epoch,
'model_state_dict': model.state_dict(),
'optimizer_state_dict': optimizer.state_dict(),
'loss': loss,
...
}, PATH)
#加载
model = TheModelClass(*args, **kwargs)
optimizer = TheOptimizerClass(*args, **kwargs)
checkpoint = torch.load(PATH)
model.load_state_dict(checkpoint['model_state_dict'])
optimizer.load_state_dict(checkpoint['optimizer_state_dict'])
epoch = checkpoint['epoch']
loss = checkpoint['loss']
model.eval()
# - or -
model.train()
model_state_dict这种名字自己起,只是字典的key值,load的时候对应好即可。
想存什么存什么,比如epoch等

1.3.2 修改删除网络后,过滤这些参数加载:

我们可能修改了一部分网络,比如加了一些,删除一些,等等,那么需要过滤这些参数,加载方式:
	def load_checkpoint(model, checkpoint, optimizer, loadOptimizer):
		if checkpoint != 'No':
			print("loading checkpoint...")
			model_dict = model.state_dict()
			modelCheckpoint = torch.load(checkpoint)
			pretrained_dict = modelCheckpoint['state_dict']
			# 过滤操作
			new_dict = {k: v for k, v in pretrained_dict.items() if k in model_dict.keys()}
			model_dict.update(new_dict)
			# 打印出来,更新了多少的参数
			print('Total : {}, update: {}'.format(len(pretrained_dict), len(new_dict)))
			model.load_state_dict(model_dict)
			print("loaded finished!")
			# 如果不需要更新优化器那么设置为false
			if loadOptimizer == True:
				optimizer.load_state_dict(modelCheckpoint['optimizer'])
				print('loaded! optimizer')
			else:
				print('not loaded optimizer')
		else:
			print('No checkpoint is included')
		return model, optimizer

详细再看
PyTorch专栏(七):模型保存与加载那些事

1.3.3 冻结部分参数,训练另一部分参数

方法一:
添加下面一句话到模型中

for p in self.parameters():
    p.requires_grad = False

比如加载了resnet预训练模型之后,在resenet的基础上连接了新的模快,resenet模块那部分可以先暂时冻结不更新,只更新其他部分的参数,那么可以在下面加入上面那句话

class RESNET_MF(nn.Module):
    def __init__(self, model, pretrained):
        super(RESNET_MF, self).__init__()
        self.resnet = model(pretrained)
        for p in self.parameters():
            p.requires_grad = False
        self.f = SpectralNorm(nn.Conv2d(2048, 512, 1))
        self.g = SpectralNorm(nn.Conv2d(2048, 512, 1))
        self.h = SpectralNorm(nn.Conv2d(2048, 2048, 1))
        ...

同时在优化器中添加:filter(lambda p: p.requires_grad, model.parameters())

optimizer = optim.Adam(filter(lambda p: p.requires_grad, model.parameters()), lr=0.001, betas=(0.9, 0.999),
                               eps=1e-08, weight_decay=1e-5)

方法二:
参数保存在有序的字典中,那么可以通过查找参数的名字对应的id值,进行冻结

查找的代码:

    model_dict = torch.load('net.pth.tar').state_dict()
    dict_name = list(model_dict)
    for i, p in enumerate(dict_name):
        print(i, p)

保存一下这个文件,可以看到大致是这个样子的:

0 gamma
1 resnet.conv1.weight
2 resnet.bn1.weight
3 resnet.bn1.bias
4 resnet.bn1.running_mean
5 resnet.bn1.running_var
6 resnet.layer1.0.conv1.weight
7 resnet.layer1.0.bn1.weight
8 resnet.layer1.0.bn1.bias
9 resnet.layer1.0.bn1.running_mean
....

同样在模型中添加这样的代码:

for i,p in enumerate(net.parameters()):
    if i < 165:
        p.requires_grad = False

在优化器中添加上面的那句话可以实现参数的屏蔽

我们的需求:屏蔽除了中间某几个结构其他所有层
首先打印出所有的self.state_dict(),然后放入到下面第二行的list中进行过滤,这些不更新。
这些代码放到模型net的def init(self):中

for i,p in enumerate(self.state_dict()):
	if i in [12,13]:
		print(i, p," 不更新")
for i,p in enumerate(self.parameters()):
	if i in [12,13]:
		p.requires_grad = False
                

查看是否真的屏蔽正确

for name, param in net.named_parameters():
    if param.requires_grad:
        print("requires_grad: True ", name)
    else:
        print("requires_grad: False ", name)

1.4 参数冻结

参考博客: 使用PyTorch加载模型部分参数方法
在深度学习领域,经常需要使用其他人已训练好的模型进行改进或微调,这个时候我们通常会希望加载预训练模型文件的参数,如果网络结构不变,只需要使用load_state_dict方法即可。而当我们改动网络结构后,由于load_state_dict方法要求读入的state_dict的key和net.state_dict()的key对应相等,如果有缺少就会报错。这个时候我们通常希望加载未改动部分结构的参数用来初始化网络。

  1. 直接使用load_state_dict提供的参数strict=False,网络结构名字一致的会被导入,不一致的会被舍弃:
    net.load_state_dict(checkpoint[‘net’], strict=False)
  2. Optimizer的加载
    在加载优化器时,需要注意如果改动了网络结构后,优化器中的参数长度可能会对应不上,这时候就会报错,例如"param ‘initial_lr’ is not specified in param_groups[*] when resuming an optimizer"。
    而优化器的load_state_dict方法没有参数strict,此时可以选择只加载优化器的基本信息,例如初始学习率initial_lr。
    optimizer_net= torch.optim.Adam([{‘params’: net.parameters(), ‘initial_lr’: 0.002}], lr=2.0e-3, betas=(0.5, 0.999))

参考博客:pytorch冻结部分参数训练另一部分
加载了resnet预训练模型之后,在resenet的基础上连接了新的模快,resenet模块那部分可以先暂时冻结不更新,只更新其他部分的参数,那么可以在下面加入上面那句话

class RESNET_MF(nn.Module):
    def __init__(self, model, pretrained):
        super(RESNET_MF, self).__init__()
        self.resnet = model(pretrained)
        for p in self.parameters():
            p.requires_grad = False   #预训练模型加载进来后全部设置为不更新参数,然后再后面加层
        self.f = SpectralNorm(nn.Conv2d(2048, 512, 1))
        self.g = SpectralNorm(nn.Conv2d(2048, 512, 1))
        self.h = SpectralNorm(nn.Conv2d(2048, 2048, 1))
        ...

同时在优化器中添加:filter(lambda p: p.requires_grad, model.parameters())

optimizer = optim.Adam(filter(lambda p: p.requires_grad, model.parameters()), lr=0.001, \
    betas=(0.9, 0.999), eps=1e-08, weight_decay=1e-5)

2 学习pytorch进阶:Pytorch-Lightning

参考博客:Pytorch Lighting 完全攻略

2.1 Crucial

  • Pytorch-Lighting 的一大特点是把模型和系统分开来看。模型是像Resnet18, RNN之类的纯模型, 而系统定义了一组模型如何相互交互,如GAN(生成器网络与判别器网络)、Seq2Seq(Encoder与Decoder网络)和Bert。同时,有时候问题只涉及一个模型,那么这个系统则可以是一个通用的系统,用于描述模型如何使用,并可以被复用到很多其他项目。

  • Pytorch-Lighting 的核心设计思想是“自给自足”。每个网络也同时包含了如何训练、如何测试、优化器定义等内容。

2.2 Hydra 库使用

【Python】Hydra 库使用记录
首先从这里我们可以看到hydra运行时,会自动建立一个输出文件夹,包含日期和时间信息,然后还会直接将路径调到里面去,以方便保存脚本内的各种东西。这就是初步测试,全部都在这一行:配置的路径在"config",配置的文件名为"config":

@hydra.main(config_path="config", config_name="config")
import hydra
from omegaconf import DictConfig, OmegaConf
from pathlib import Path

@hydra.main(config_path="config", config_name="config")

def main(config):
    running_dir = str(hydra.utils.get_original_cwd())
    working_dir = str(Path.cwd())
    print(f"The current running directory is {running_dir}")
    print(f"The current working directory is {working_dir}")

    # To access elements of the config
    print(f"The batch size is {config.batch_size}")
    print(f"The learning rate is {config['lr']}")

if __name__ == "__main__":
    main()

### config/config.yaml
batch_size: 10
lr: 1e-4

2.2.1 函数装饰器

参考使用Hydra库管理超参数

import hydra
from omegaconf import DictConfig, OmegaConf

@hydra.main(version_base=None, config_path="conf", config_name="config")
def my_app(cfg : DictConfig) -> None:
    print(OmegaConf.to_yaml(cfg))

if __name__ == "__main__":
    my_app()

这段程序最终输出的是 ./conf/config.yaml 文件中的配置信息

按照 菜鸟教程 中的说法,Python中的装饰器 (Decorators) 是用于修改其他函数功能的函数,帮助让代码更加简洁。对函数装饰器最简单的理解即——将函数作为参数传给另一个函数,如下面这个例子

def hi():
    return "hi yasoob!"
 
def doSomethingBeforeHi(func):
    print("I am doing some boring work before executing hi()")
    print(func())
 
doSomethingBeforeHi(hi)
#outputs:I am doing some boring work before executing hi()
#        hi yasoob!

这里函数 doSomethingBeforeHi() 其实就可以看作是函数 hi() 的装饰器,在运行过程中,首先在 doSomethingBeforeHi 函数内部添加一些额外内容(在添加这部分内容的部分可以看作是对于原函数功能的修改/补充),之后运行/返回作为参数的 hi 函数

更加直观的例子来自于 该博客, 如下代码段所示,这一例子也能很好地体现函数装饰器对于已有函数功能的完善作用。

2.2.2 python 冒号

见过一个大佬写的代码是这样的:

user: User = User.objects.filter(id=data.get('uid')).first()

变量名后面的冒号是:类型注解,3.6以后加入的,冒号右边是类型,仅仅是注释,有些鸡肋

变量注释的语法:注释变量类型,明确指出变量类型,方便帮助复杂案例中的类型推断。

var: type = value  其实本质上就是  var = value  # type就是var期望的类型

类型注释只是一种提示,并非强制的,Python解释器不会去校验value的类型是否真的是type

2.2.3 使用Hydra构建实例对象

Hydra 提供hydra.utils.instantiate()(及其别名hydra.utils.call())用于实例化对象和调用函数。更喜欢instantiate创建对象和call调用函数。

传递给这些函数的配置必须有一个名为_target_的键,其值为完全限定的类名、类方法、静态方法或callable。为方便起见,Noneconfig 结果是一个None对象。
onepose代码中的栗子

model:
  _target_: src.lightning_model.OnePosePlus_lightning_model.PL_OnePosePlus
  pretrained_ckpt: "weight/epoch=3-v1.ckpt"

  OnePosePlus:
    loftr_backbone:

命名参数:配置字段(保留字段除外,如_target_)作为命名参数传递给目标。可以通过在call-site中传递具有相同名称的命名参数来覆盖配置中的命名参数instantiate()

位置参数:配置可能包含一个_args_字段,表示要传递给目标的位置参数。instantiate()可以通过在call-site中传递位置参数来一起覆盖位置参数。

附录

第一章节全部代码

import torchvision
import torchvision.transforms as transforms
import torch
import numpy as np
import torch.nn as nn
import torch.nn.functional as F
import torch.optim
import matplotlib.pyplot as plt
import numpy as np
from torch.autograd import Variable

mode = 'train'
model_path = './model/weight1.ckpt'

# torchvision数据集的输出是在[0, 1]范围内的PILImage图片。
# 我们此处使用归一化的方法将其转化为Tensor,数据范围为[-1, 1]

transform=transforms.Compose([transforms.ToTensor(),
                              transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5)),
                             ])
trainset = torchvision.datasets.CIFAR10(root='./data', train=True, download=True, transform=transform)
trainloader = torch.utils.data.DataLoader(trainset, batch_size=4, 
                                          shuffle=True, num_workers=2)

testset = torchvision.datasets.CIFAR10(root='./data', train=False, download=True, transform=transform)
testloader = torch.utils.data.DataLoader(testset, batch_size=4, 
                                          shuffle=False, num_workers=2)
classes = ('plane', 'car', 'bird', 'cat',
           'deer', 'dog', 'frog', 'horse', 'ship', 'truck')
# functions to show an image

def imshow(img):
    img = img / 2 + 0.5 # unnormalize
    npimg = img.numpy()
    plt.imshow(np.transpose(npimg, (1,2,0)))

# # show some random training images
# dataiter = iter(trainloader)
# images, labels = dataiter.next()

# # print images
# imshow(torchvision.utils.make_grid(images))
# # print labels
# print(' '.join('%5s'%classes[labels[j]] for j in range(4)))

#  定义一个卷积神经网络
class Net(nn.Module):
    def __init__(self):
        super(Net, self).__init__()
        self.conv1 = nn.Conv2d(3, 6, 5)
        self.pool  = nn.MaxPool2d(2,2)
        self.conv2 = nn.Conv2d(6, 16, 5)
        self.fc1   = nn.Linear(16*5*5, 120)
        self.fc9   = nn.Linear(120, 84)
        self.fc2_1   = nn.Linear(84, 42)
        self.fc2_2   = nn.Linear(42, 84)
        self.fc3   = nn.Linear(84, 10)
        for i,p in enumerate(self.state_dict()):
            if i in [12,13]:
                print(i, p," 不更新")
        for i,p in enumerate(self.parameters()):
            if i < 10:
                p.requires_grad = False
                

    def forward(self, x):
        x = self.pool(F.relu(self.conv1(x)))
        x = self.pool(F.relu(self.conv2(x)))
        x = x.view(-1, 16*5*5)
        x = F.relu(self.fc1(x))
        x = F.relu(self.fc9(x))
        # x = F.relu(self.fc2_1(x))
        # x = F.relu(self.fc2_2(x))
        x = self.fc3(x)
        return x

net = Net()
# 定义代价函数和优化器
criterion = nn.CrossEntropyLoss() # use a Classification Cross-Entropy loss
# optimizer = torch.optim.SGD(net.parameters(), lr=0.001, momentum=0.9)

for name, param in net.named_parameters():
    if param.requires_grad:
        print("requires_grad: True ", name)
    else:
        print("requires_grad: False ", name)

optimizer = torch.optim.SGD(filter(lambda p: p.requires_grad, net.parameters()), lr=0.001, momentum=0.9)


# 加载模型
checkpoint=torch.load(model_path)
model_dict = net.state_dict()  # 这个权重字典都是初始值
pretrained_dict = checkpoint['state_dict']  # 获取权重文件的权重字典,为了后面更新初始值权重字典
new_dict = {k: v for k, v in pretrained_dict.items() if k in model_dict.keys()}  # 过滤掉新增加的或者修改的或者删除的,防止更新的时候对应不上
model_dict.update(new_dict)  # 更新初始权重字典(没有的都是初始值)

print('Total : {}, update: {}'.format(len(pretrained_dict), len(new_dict)))
net.load_state_dict(model_dict)  # 更新网络参数

optimizer.load_state_dict(checkpoint['optimizer'])
print('loaded! optimizer')
print("loaded finished!")




if mode == 'train':
    # 训练网络
    for epoch in range(1): # loop over the dataset multiple times
        
        running_loss = 0.0
        for i, data in enumerate(trainloader, 0):
            # get the inputs
            inputs, labels = data
            
            # wrap them in Variable
            inputs, labels = Variable(inputs), Variable(labels)
            
            # zero the parameter gradients
            optimizer.zero_grad()
            
            # forward + backward + optimize
            outputs = net(inputs)
            loss = criterion(outputs, labels)
            loss.backward()        
            optimizer.step()
            
            # print statistics
            running_loss += loss.data
            if i % 2000 == 1999: # print every 2000 mini-batches
                print('[%d, %5d] loss: %.3f' % (epoch+1, i+1, running_loss / 2000))
                running_loss = 0.0
    # 方式一
    # torch.save(net,model_path)
    # 方式二
    torch.save({
		'state_dict': net.state_dict(),
        'optimizer': optimizer.state_dict()},
        model_path)
    print('Finished Training')
    
    

# 模型保存与加载
else :
    # 正常加载
    # checkpoint=torch.load(model_path)
    
    # net.load_state_dict(checkpoint['state_dict'])
    # optimizer.load_state_dict(checkpoint['optimizer'])

    # 修改网络后加载
    checkpoint=torch.load(model_path)
    model_dict = net.state_dict()  # 这个权重字典都是初始值
    pretrained_dict = checkpoint['state_dict']  # 获取权重文件的权重字典,为了后面更新初始值权重字典
    new_dict = {k: v for k, v in pretrained_dict.items() if k in model_dict.keys()}  # 过滤掉新增加的或者修改的或者删除的,防止更新的时候对应不上
    model_dict.update(new_dict)  # 更新初始权重字典(没有的都是初始值)
    
    print('Total : {}, update: {}'.format(len(pretrained_dict), len(new_dict)))
    net.load_state_dict(model_dict)  # 更新网络参数
    print("loaded finished!")
    
    
    # for i,p in enumerate(net.state_dict()):
    #     print(i, p)
    # print(checkpoint)


# 展示几张照片
dataiter = iter(testloader)
images, labels = dataiter.next()

# print images
imshow(torchvision.utils.make_grid(images))
print('GroundTruth: ', ' '.join('%5s'%classes[labels[j]] for j in range(4)))

# 推理
correct = 0
total = 0
for data in testloader:
    images, labels = data
    outputs = net(Variable(images))
    _, predicted = torch.max(outputs.data, 1)
    total += labels.size(0)
    correct += (predicted == labels).sum()

print('Accuracy of the network on the 10000 test images: %d %%' % (100 * correct / total))

  • 0
    点赞
  • 30
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值