【PyTorch框架】 迁移学习 & 模型微调

一、原因

  • 模型训练有两种方式:从头开始学习(Learning from Scratch)和迁移学习(Transfer Learning)。
  • 迁移学习的原因:
    数据集小。模型的大量参数需要海量的训练数据进行训练。
    借助迁移学习,在一个模型训练任务中针对某种类型数据获得的关系也可以轻松地应用于同一领域的不同问题。

二、迁移与微调

1. 迁移学习

  • Transfer Learning(迁移学习):机器学习分支,研究源域(source domain)的知识如何应用到目标域(target domain)。我们将源任务所学习到的知识(模型参数-权值)应用到目标任务当中,用来提升目标任务中的模型性能,而不用像大多数网络那样从零学习。
    在这里插入图片描述
    上图的说明
    (a)图:是一个传统的机器学习过程。对不同的任务分别进行学习,得到一个称之为Learning System的模型,三个不同任务就会得到三个不同的模型;
    (b)图:是迁移学习过程,分为源任务和目标任务。这两个任务之间有一定的关联,首先对源任务进行学习,学习得到的参数称之为knowledge知识(权值),而在目标任务中利用源任务里学习到的knowledge进行学习、训练,最终得到Learning System。
    参考文献:《A Survey On Transfer Learning》

  • 实现迁移学习有以下三种手段:
    Transfer Learning:冻结预训练模型的全部卷积层,只训练自己定制的全连接层。
    Extract Feature Vector:先计算出预训练模型的卷积层对所有训练和测试数据的特征向量,然后抛开预训练模型,只训练自己定制的简配版全连接网络。
    Fine-tuning:冻结预训练模型的部分卷积层(通常是靠近输入的多数卷积层,因为这些层保留了大量底层信息)甚至不冻结任何网络层,训练剩下的卷积层(通常是靠近输出的部分卷积层)和全连接层。

  • 理想情况下,在一个成功的迁移学习应用中,将会获得以下益处:
    更高的起点:在微调之前,源模型的初始性能要比不使用迁移学习来的高。
    更高的斜率:在训练的过程中源模型提升的速率要比不使用迁移学习来得快。
    更高的渐进:训练得到的模型的收敛性能要比不使用迁移学习更好。

2. 模型微调

模型微调就是模型的迁移学习。
建议: 如果不考虑效率的话,建议用优化器设置,因为学习率设置比较灵活。

  • 原因:
    在新任务中数据量较小,不足以训练一个较大的模型。因此,采用模型微调在新任务上辅助训练一个较好的模型,让训练过程更快。
    在这里插入图片描述
  • 模型微调步骤:
    以卷积神经网路为例,特征提取部分Features Extractor认为是非常有共性的地方,我们可以原封不动的进行迁移;分类器classifier的参数认为与具体的任务有关,分类器中的输出层通常需要进行改变。
    在这里插入图片描述
    (1) 获取预训练模型参数;
    (2) 加载参数至模型(load_state_dict);
    (3) 修改输出层fc以适应新任务。
  • 模型微调的两种训练方法:
    固定预训练的参数(第一种方法requires_grad =False;第二种方法lr=0),即不更新参数的梯度,或者学习率为0。
    代码段: 所谓冻结卷积层,是直接把参数的梯度设置为False。
# 冻结卷积层,采用的方法是requires_grad =False
# flag_m1 = 0
flag_m1 = 1
if flag_m1:
    for param in resnet18_ft.parameters():
        param.requires_grad = False

    原因: 新任务的数据量太小,预训练参数已经具有共性,不再需要改变,如果再用这些小数据训练,可能反而过拟合。

    √ Features Extractor设置较小学习率(params_group)。
    代码段:

# conv 小学习率
# flag = 0
flag = 1
if flag:
    fc_params_id = list(map(id, resnet18_ft.fc.parameters()))     # 返回的是parameters的内存地址
    base_params = filter(lambda p: id(p) not in fc_params_id, resnet18_ft.parameters())
    # 优化器设置不同的参数组,优化器中的元素是一个list,list中的每一个元素是字典
    optimizer = optim.SGD([
        {'params': base_params, 'lr': LR*0.1},   # 0
        {'params': resnet18_ft.fc.parameters(), 'lr': LR}], momentum=0.9)

   说明: 对于分类任务来说,在特征提取的地方设置较小的学习率,而在全连接层的分类器可以设置较大的学习率。利用参数组的概念,由于优化器可以对不同的参数组设置不同的学习率,让特征提取的学习率较小,全连接层的学习率较大,这就实现了不同的参数设置不同的学习率。

三、PyTorch中模型微调-Resnet-18 用于二分类

  • ResNet-18的模型架构:
    在这里插入图片描述
    说明: ResNet-18是在ImageNet数据集上训练的,这里是1000类的分类任务,但由于我们目前要做的是个二分类,因此在fc层这里需要进行更改,将1000个神经元改为2个神经元。
  • 数据构建Dataset: my_dataset.py
class AntsDataset(Dataset):
    def __init__(self, data_dir, transform=None):
        self.label_name = {"ants": 0, "bees": 1}
        # data_info重要,是一个list,在getitem中告诉dataloaderer图片、标签所在的位置
        self.data_info = self.get_img_info(data_dir)  
        self.transform = transform
    
    # 实现是拿到样本序号index,读取返回img, label,在这里不要做过多的操作
    def __getitem__(self, index):
        path_img, label = self.data_info[index]
        img = Image.open(path_img).convert('RGB')

        if self.transform is not None:
            img = self.transform(img)

        return img, label

    def __len__(self):
        return len(self.data_info)

    def get_img_info(self, data_dir):
        data_info = list()
        for root, dirs, _ in os.walk(data_dir):
            # 遍历类别
            for sub_dir in dirs:
                img_names = os.listdir(os.path.join(root, sub_dir))
                img_names = list(filter(lambda x: x.endswith('.jpg'), img_names))

                # 遍历图片
                for i in range(len(img_names)):
                    img_name = img_names[i]
                    path_img = os.path.join(root, sub_dir, img_name)
                    label = self.label_name[sub_dir]
                    data_info.append((path_img, int(label)))

         # 合法性的判断:判断当前data_dir是否有图像,如果没有,就给出异常,说是空文件夹
        if len(data_info) == 0:
            raise Exception("\ndata_dir:{} is a empty dir! Please checkout your path to images!".format(data_dir))
        return data_info

1. 直接训练

不采用model finetune的情况,随机初始化Resnet-18然后进行训练。

  • 训练结果: 准确率70%左右
    在这里插入图片描述
  • loss曲线: 在0.5-0.6左右,loss比较高
    在这里插入图片描述

2. 迁移训练,但不冻结卷积层,固定学习率

  • 方法: 加载模型参数和修改fc层
# ============================ step 2/5 模型 ============================
# 1/3 构建模型
resnet18_ft = models.resnet18()

# 2/3 加载参数
# flag = 0
flag = 1
if flag:
    path_pretrained_model = os.path.join(BASEDIR, "..", "..", "data/resnet18-5c106cde.pth")
    state_dict_load = torch.load(path_pretrained_model)
    resnet18_ft.load_state_dict(state_dict_load)

# 3/3 替换fc层
num_ftrs = resnet18_ft.fc.in_features
resnet18_ft.fc = nn.Linear(num_ftrs, classes)

resnet18_ft.to(device)
  • 学习率设置: 模型所有参数采用相同的学习率
# ============================ step 4/5 优化器 ============================
# --------------------------所有参数选择一个学习率--------------------------
flag = 0
# flag = 1  
# Python程序语言指定任何非0和非空(null)值为true,0 或者 null为false。"判断条件"成立时/条件为“true"(非零),则执行后面的语句
if flag:
    fc_params_id = list(map(id, resnet18_ft.fc.parameters()))     # 返回的是parameters的 内存地址
    base_params = filter(lambda p: id(p) not in fc_params_id, resnet18_ft.parameters()) # 将resnet18中的参数过滤掉fc层的参数后得到base_params
    optimizer = optim.SGD([
        {'params': base_params, 'lr': LR*0},   # 0
        {'params': resnet18_ft.fc.parameters(), 'lr': LR}], momentum=0.9)

else:  # 执行这条语句
    optimizer = optim.SGD(resnet18_ft.parameters(), lr=LR, momentum=0.9)               # 选择优化器

scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=lr_deca y_step, gamma=0.1)     # 设置学习率下降策略
  • 训练结果:
    在这里插入图片描述
    说明: 刚开始训练时的准确率就很高,Model Finetune的目的就是更快地让模型达到更好的效果。

3. 迁移训练,第一种方法:冻结卷积层,固定学习率

  • 方法:
    冻结卷积层,并打印训练前的卷积层参数:
# ============================ step 2/5 模型 ============================

# 1/3 构建模型
resnet18_ft = models.resnet18()

# 2/3 加载参数
# flag = 0
flag = 1
if flag:
    path_pretrained_model = os.path.join(BASEDIR, "..", "..", "data/resnet18-5c106cde.pth")
    state_dict_load = torch.load(path_pretrained_model)
    resnet18_ft.load_state_dict(state_dict_load)

# 法1 : 冻结卷积层
# flag_m1 = 0
flag_m1 = 1
if flag_m1:
    for param in resnet18_ft.parameters():  #对resnet18_ft的所有参数进行for循环
        param.requires_grad = False  # 参数不求取梯度,不更新参数
    # 打印训练前的卷积层的参数
    print("conv1.weights[0, 0, ...]:\n {}".format(resnet18_ft.conv1.weight[0, 0, ...]))

# 3/3 替换fc层
num_ftrs = resnet18_ft.fc.in_features
resnet18_ft.fc = nn.Linear(num_ftrs, classes)

resnet18_ft.to(device)

训练中的卷积层参数打印出来:

 # 打印训练信息
        loss_mean += loss.item()
        train_curve.append(loss.item())
        if (i + 1) % log_interval == 0:
            loss_mean = loss_mean / log_interval
            print("Training:Epoch[{:0>3}/{:0>3}] Iteration[{:0>3}/{:0>3}] Loss: {:.4f} Acc:{:.2%}".format(
                epoch, MAX_EPOCH, i + 1, len(train_loader), loss_mean, correct / total))
            loss_mean = 0.

            # 训练中卷积层参数打印出来
            if flag_m1:
            print("epoch:{} conv1.weights[0, 0, ...] :\n {}".format(epoch, resnet18_ft.conv1.weight[0, 0, ...]))
  • 训练结果: 卷积层参数没有更新
    在这里插入图片描述
    在这里插入图片描述

4. 迁移训练,第二种方法:冻结卷积层,卷积层的学习率在优化器设置0,全连接层的学习率正常设置

  • 冻结卷积层:
# 冻结卷积层
# flag_m1 = 0
flag_m1 = 1
if flag_m1:
    for param in resnet18_ft.parameters():  #对resnet18_ft的所有参数进行for循环
        param.requires_grad = False  # 参数不求取梯度,不更新参数
    # 打印训练前的卷积层的参数
    print("conv1.weights[0, 0, ...]:\n {}".format(resnet18_ft.conv1.weight[0, 0, ...]))
  • 设置优化器: 卷积层的参数优化器设置LR*0
# flag = 0
flag = 1  # 真
# Python程序语言指定任何非0和非空(null)值为true,0 或者 null为false。"判断条件"成立时/条件为“true"(非零),则执行后面的语句
if flag:
    fc_params_id = list(map(id, resnet18_ft.fc.parameters()))     # 返回的是fc层的parameters的内存地址
    base_params = filter(lambda p: id(p) not in fc_params_id, resnet18_ft.parameters()) # 将resnet18中的参数过滤掉fc层的参数后得到base_params==卷积层参数
    optimizer = optim.SGD([
        {'params': base_params, 'lr': LR*0},   # 0
        {'params': resnet18_ft.fc.parameters(), 'lr': LR}], momentum=0.9)  # 10^-3,两个参数的momentum都是0.9

else:
    optimizer = optim.SGD(resnet18_ft.parameters(), lr=LR, momentum=0.9)               # 选择优化器

scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=lr_deca y_step, gamma=0.1)     # 设置学习率下降策略
  • 打印训练过程中的卷积层参数:
    在这里插入图片描述
    在这里插入图片描述
    说明: 卷积层的参数没有变化,为0.0104,说明优化器中的卷积层学习率设为0,实现了固定参数。
    建议: 如果不考虑效率的话,建议用第二种方法,即优化器设置,因为学习率设置比较灵活。
  • 结果: 可以在较快的时间内达到较好的实验结果。
    在这里插入图片描述

5. 迁移训练,卷积层设置较小的学习率,全连接层设置较大的学习率,需要用到优化器

  • 方法:
# ============================ step 4/5 优化器 ============================
# --------------------法2 : conv卷积层较小学习率,全连接层较大学习率----------------
# flag = 0
flag = 1  # 真
# Python程序语言指定任何非0和非空(null)值为true,0 或者 null为false。"判断条件"成立时/条件为“true"(非零),则执行后面的语句
if flag:
    fc_params_id = list(map(id, resnet18_ft.fc.parameters()))     # 返回的是fc层的parameters的内存地址
    base_params = filter(lambda p: id(p) not in fc_params_id, resnet18_ft.parameters()) # 将resnet18中的参数过滤掉fc层的参数后得到base_params==卷积层参数
    optimizer = optim.SGD([
        {'params': base_params, 'lr': LR*0.1},   # 第一个LR*0.1
        {'params': resnet18_ft.fc.parameters(), 'lr': LR}], momentum=0.9) # 10^-3,两个参数的momentum都是0.9

else:
    optimizer = optim.SGD(resnet18_ft.parameters(), lr=LR, momentum=0.9)               # 选择优化器

scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=lr_deca y_step, gamma=0.1)     # 设置学习率下降策略

另外一个小技巧就是在nn.Module里冻结参数,这样前面的参数就是False,而后面的不变。

class Net(nn.Module):
    def __init__(self):
        super(Net, self).__init__()
        self.conv1 = nn.Conv2d(1, 6, 5)
 
        for p in self.parameters():
            p.requires_grad=False
 
        self.fc1 = nn.Linear(16 * 5 * 5, 120)

唯一需要注意的是,pytorch 固定部分参数训练时需要在优化器中施加过滤。

optimizer = optim.Adam(filter(lambda p: p.requires_grad, net.parameters()), lr=0.1)

6. 代码

运行finetune_resnet18.py

# -*- coding: utf-8 -*-
"""
# @file name  : finetune_resnet18.py
# @brief      : 模型finetune方法
"""
import os
import numpy as np
import torch
import torch.nn as nn
from torch.utils.data import DataLoader
import torchvision.transforms as transforms
import torch.optim as optim
from matplotlib import pyplot as plt
from tools.my_dataset import AntsDataset
from tools.common_tools import set_seed
import torchvision.models as models
import torchvision
BASEDIR = os.path.dirname(os.path.abspath(__file__))
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print("use device :{}".format(device))

set_seed(1)  # 设置随机种子
label_name = {"ants": 0, "bees": 1}

# 参数设置
MAX_EPOCH = 25
BATCH_SIZE = 16
LR = 0.001
log_interval = 10
val_interval = 1
classes = 2
start_epoch = -1
lr_decay_step = 7


# ============================ step 1/5 数据 ============================
data_dir = os.path.join(BASEDIR, "..", "..", "data/hymenoptera_data")
train_dir = os.path.join(data_dir, "train")
valid_dir = os.path.join(data_dir, "val")

norm_mean = [0.485, 0.456, 0.406]
norm_std = [0.229, 0.224, 0.225]

train_transform = transforms.Compose([
    transforms.RandomResizedCrop(224),
    transforms.RandomHorizontalFlip(),
    transforms.ToTensor(),
    transforms.Normalize(norm_mean, norm_std),
])

valid_transform = transforms.Compose([
    transforms.Resize(256),
    transforms.CenterCrop(224),
    transforms.ToTensor(),
    transforms.Normalize(norm_mean, norm_std),
])

# 构建MyDataset实例
train_data = AntsDataset(data_dir=train_dir, transform=train_transform)
valid_data = AntsDataset(data_dir=valid_dir, transform=valid_transform)

# 构建DataLoder
train_loader = DataLoader(dataset=train_data, batch_size=BATCH_SIZE, shuffle=True)
valid_loader = DataLoader(dataset=valid_data, batch_size=BATCH_SIZE)

# ============================ step 2/5 模型 ============================

# 1/3 构建模型
resnet18_ft = models.resnet18()

# 2/3 加载预训练模型的参数
# flag = 0
flag = 1
if flag:
    path_pretrained_model = os.path.join(BASEDIR, "..", "..", "data/resnet18-5c106cde.pth")
    state_dict_load = torch.load(path_pretrained_model)
    resnet18_ft.load_state_dict(state_dict_load)

# ----------------------法1 : 冻结卷积层------------------------
flag_m1 = 0
# flag_m1 = 1
if flag_m1:
    for param in resnet18_ft.parameters():
        param.requires_grad = False #参数不求取梯度,即参数不再更新
    print("conv1.weights[0, 0, ...]:\n {}".format(resnet18_ft.conv1.weight[0, 0, ...]))


# 3/3 替换fc层,适应新任务:设置新的Linear层
num_ftrs = resnet18_ft.fc.in_features #获取原始resnet18模型fc层features的个数
# 构建Linear层的两个参数:输入神经元个数,输出神经元个数=
resnet18_ft.fc = nn.Linear(num_ftrs, classes)


resnet18_ft.to(device) #将模型放到cpu or gpu
# ============================ step 3/5 损失函数 ============================
criterion = nn.CrossEntropyLoss()                                                   # 选择损失函数

# ============================ step 4/5 优化器 ============================
# --------------------法2 : conv卷积层较小学习率,全连接层较大学习率----------------
# flag = 0
flag = 1  # 真
# Python程序语言指定任何非0和非空(null)值为true,0 或者 null为false。"判断条件"成立时/条件为“true"(非零),则执行后面的语句
if flag:
    fc_params_id = list(map(id, resnet18_ft.fc.parameters()))     # 返回的是parameters的 内存地址
    base_params = filter(lambda p: id(p) not in fc_params_id, resnet18_ft.parameters()) # 将resnet18中的参数过滤掉fc层的参数后得到base_params
    optimizer = optim.SGD([
        {'params': base_params, 'lr': LR*0},   # 0
        {'params': resnet18_ft.fc.parameters(), 'lr': LR}], momentum=0.9)

else:
    optimizer = optim.SGD(resnet18_ft.parameters(), lr=LR, momentum=0.9)               # 选择优化器

scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=lr_deca y_step, gamma=0.1)     # 设置学习率下降策略


# ============================ step 5/5 训练 ============================
train_curve = list()
valid_curve = list()

for epoch in range(start_epoch + 1, MAX_EPOCH):

    loss_mean = 0.
    correct = 0.
    total = 0.

    resnet18_ft.train()
    for i, data in enumerate(train_loader):

        # forward
        inputs, labels = data
        inputs, labels = inputs.to(device), labels.to(device) #将数据、标签放到cpu or gpu
        outputs = resnet18_ft(inputs)

        # backward
        optimizer.zero_grad()
        loss = criterion(outputs, labels)
        loss.backward()

        # update weights
        optimizer.step()

        # 统计分类情况
        _, predicted = torch.max(outputs.data, 1)
        total += labels.size(0)
        correct += (predicted == labels).squeeze().cpu().sum().numpy()

        # 打印训练信息
        loss_mean += loss.item()
        train_curve.append(loss.item())
        if (i+1) % log_interval == 0:
            loss_mean = loss_mean / log_interval
            print("Training:Epoch[{:0>3}/{:0>3}] Iteration[{:0>3}/{:0>3}] Loss: {:.4f} Acc:{:.2%}".format(
                epoch, MAX_EPOCH, i+1, len(train_loader), loss_mean, correct / total))
            loss_mean = 0.

            # if flag_m1:
            print("epoch:{} conv1.weights[0, 0, ...] :\n {}".format(epoch, resnet18_ft.conv1.weight[0, 0, ...]))

    scheduler.step()  # 更新学习率

    # validate the model
    if (epoch+1) % val_interval == 0:

        correct_val = 0.
        total_val = 0.
        loss_val = 0.
        resnet18_ft.eval()
        with torch.no_grad():
            for j, data in enumerate(valid_loader):
                inputs, labels = data
                inputs, labels = inputs.to(device), labels.to(device)

                outputs = resnet18_ft(inputs)
                loss = criterion(outputs, labels)

                _, predicted = torch.max(outputs.data, 1)
                total_val += labels.size(0)
                correct_val += (predicted == labels).squeeze().cpu().sum().numpy()

                loss_val += loss.item()

            loss_val_mean = loss_val/len(valid_loader)
            valid_curve.append(loss_val_mean)
            print("Valid:\t Epoch[{:0>3}/{:0>3}] Iteration[{:0>3}/{:0>3}] Loss: {:.4f} Acc:{:.2%}".format(
                epoch, MAX_EPOCH, j+1, len(valid_loader), loss_val_mean, correct_val / total_val))
        resnet18_ft.train()

train_x = range(len(train_curve))
train_y = train_curve

train_iters = len(train_loader)
valid_x = np.arange(1, len(valid_curve)+1) * train_iters*val_interval # 由于valid中记录的是epochloss,需要对记录点进行转换到iterations
valid_y = valid_curve

plt.plot(train_x, train_y, label='Train')
plt.plot(valid_x, valid_y, label='Valid')

plt.legend(loc='upper right')
plt.ylabel('loss value')
plt.xlabel('Iteration')
plt.show()

四、GPU的使用

# 第一步: 设置device
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
# 第二步: 使用to方法将模型和训练数据都放到GPU上
resnet18_ft.to(device)
# 第三步: 将训练数据都放到GPU上
inputs, labels = inputs.to(device), labels.to(device)
# 第四步: 使用GPU上的模型对GPU上的数据进行计算
outputs = resnet18_ft(inputs)

五、参考

[1] 简述迁移学习
[2] 深度学习11— 为什么需要迁移学习?+迁移学习简介
[3] [二十六]深度学习Pytorch-迁移学习、模型微调Finetune
[4] PyTorch学习—20.模型的微调(Finetune)
[5] 【深度之眼】【Pytorch打卡第16天】:模型微调Finetune(迁移学习)
[6] 【深度之眼】Pytorch框架班第五期-Finetune代码解析
[7] PyTorch框架学习二十——模型微调(Finetune)
[8] Pytorch系列之——模型保存与加载、finetune
[9] Pytorch基础学习(第七章-Pytorch训练技巧)
[10] 07-02-模型finetune
[11] pytorch冻结部分参数训练另一部分

  • 2
    点赞
  • 27
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
PyTorch是一个流行的深度学习框架,它提供了丰富的工具和函数来构建和训练神经网络模型迁移学习是一种利用预训练模型的技术,通过在新任务上微调预训练模型来加快模型训练的过程。 下面是一个使用PyTorch进行迁移学习的回归模型示例,该型包含4层的反向传播backpropagation): 1. 导入必要的库和模块: ```python torch import torch.nn as nn import torch.optim as optim from torchvision import models ``` 2. 加载预训练模型: python model = models.resnet18(pretrained=True) ``` 这里使用了ResNet-18作为预训练模型,你也可以选择其他的预训练模型。 3. 冻结预训练模型的参数: ```python for param in model.parameters(): param.requires_grad = False ``` 通过将参数的`requires_grad`属性设置为False,可以冻结预训练模型的参数,使其在微调过程中不会被更新。 4. 替换最后一层全连接层: ```python num_features = model.fc.in_features model.fc = nn.Linear(num_features, 1) ``` 这里将预训练模型的最后一层全连接层替换为一个只有一个输出节点的线性层,用于回归任务。 5. 定义损失函数和优化器: ```python criterion = nn.MSELoss() optimizer = optim.SGD(model.fc.parameters(), lr=0.001, momentum=0.9) ``` 这里使用均方误差(MSE)作为损失函数,随机梯度下降(SGD)作为优化器。 6. 训练模型: ```python for epoch in range(num_epochs): # 前向传播 outputs = model(inputs) loss = criterion(outputs, labels) # 反向传播和优化 optimizer.zero_grad() loss.backward() optimizer.step() ``` 在每个训练周期中,通过前向传播计算输出并计算损失,然后进行反向传播和优化来更新模型的参数。 这是一个简单的示例,你可以根据自己的需求进行修改和扩展。希望对你有所帮助!

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值