一文了解迁移学习和微调&使用Tensorflow实现迁移学习和微调 (构建CNN图像分类模型)

一、迁移学习简介

1.1 Why using transfer-learning?为何使用迁移学习?🌑

相信对于深度学习有一定了解的大家都知道一个不争的事实

一个良好的神经网络模型的训练往往需要大规模的数据,占用大量的计算资源,经过大量的时间来进行训练。

eg: Google为了参加 ISLVR (ImageNet Large Scale Visual Recognition Challenge)比赛,往往使用了上百万张数据图像,使用多个装有GPU的服务器训练2-3周才完成一个模型。

而对于我们绝大多数人来说,往往是没有时间,没有资源在大量的数据集上训练模型。(当然你要是有钱就当我没说😜)还有可能出现以下的情况:

上联: “模型训练一个月”
下联: ”一看效果又傻眼“
横批: “白忙一月”
🤣🤣🤣 (也就是自己构建的模型效果不佳)

事实上,在实际的应用中,深度学习的相关研究人员和从业者往往使用迁移学习(transfer-learning)的方法来使用ImageNet等数据集上训练的现有模型(VGG,ResNet等…这些模型也被称为预训练模型)。

1.2 What is transfer-learning?什么是迁移学习?🌒

使用预训练的模型(VGG,ResNet等…),将这些模型的底部特征提取层的网络权重,传递给新的分类网络,这就叫做迁移学习。这种做法并不是个例。更加直白的说:也就是只使用这些模型的特征提取层来提取特征,然后在这些提取层的基础上构建新的模型。
Feature Extraction with VGG16 ---Small Manhole Cover Detection in Remote Sensing Imagery with Deep Convolutional Neural Networks
eg: 一个使用VGG16来提取特征的例子 (Image: Feature Extraction with VGG16 Model)

迁移学习:在ImageNet(现有的全世界最大的分类好的图片库)上得到一个预训练好的神经网络,删除网络顶部的全连接层(见上图红色的叉❌),然后将网络的剩余部分作为新数据集的特征提取层。这也就是说,我们使用了ImageNet提取到的图像特征,为新数据集训练分类器。

微调迁移学习的一种实现方法!!!!,更换或者重新训练网络顶部的分类器,还可以通过反向传播算法调整预训练网络的权重。

二、实现迁移学习(不进行微调,仅仅用于特征提取,By: Tensorflow 2.0+)

大约讲了一下迁移学习的核心思想、为什么要使用迁移学习。相信大家对于如何迁移学习这个名词已经有所了解,那么现在介绍一下如何使用代码来实现迁移学习。

Tip: 此处使用的是Tensorflow框架,版本:2.0+ (Tensorflow的1.0版本和2.0版本存在许多不同,使用的时候要格外注意!)

2.1 任务概述🌓

最近博主本人(大学生)参加了几个比赛(美赛&大学生计算机设计大赛),在两个比赛中都使用到了迁移学习的方法,而且是两种不同的实现方式,一种进行了微调,一种没有,均构建了针对比赛任务的CNN图像分类模型。因而打算将介绍一下两个比赛的不同任务,并对两种不同的迁移学习的实现方法进行比较。

后续会专门写一篇博客记录一下2021年美赛C题中我所使用的诸多方法(例如:图像增强,文本向量化,多模态等…)感兴趣的小伙伴和大佬们欢迎关注一下!写完后我会把链接贴在本博客的最下方!
当前:未写完美赛博客(2021-06-27)
个人美赛思路:链接

说了点废话,我们重回主题:

  • 美赛(子)任务:针对美国当地物种入侵网站上人们上传的亚洲胡蜂图片以及相关专家对于其中一些图片的判断,构建一个图像分类模型来初步判断图像中的蜜蜂是亚洲胡蜂的概率。

  • 计算机设计大赛任务:针对不同古代名人篆刻的印章图片,构建一个图像分类的CNN模型,判断一张图片与哪位篆刻名家的风格比较接近。

2.2 模型构建🌔

这里博主先将各个部分的代码分开解释,在最后会将完整代码进行展示。

2.2.1 哪里能够找到预训练模型?

在tensorflow.keras.applications中存在许多预训练的模型,可以直接使用,非常方便。
若想了解有哪些预训练模型,请参照以下Tensorflow官网文档:官方文档
Eg: VGG16 model:

# eg: tensorflow中的VGG16预训练模型
'''
ARGs:
	# include_top:			是否要使用该神经网络的最上方的三个全连接层
	# weights:				加载的权重(默认的imagenet代表使用的是预训练模型)
	# input_tensor: 		用来作为模型的输入层(optional)
	# input_shape: 		只有在include_top为False时使用(因为True的时候VGG模型的输入的矩阵形状是确定的)
	# classes: 			分类的类别数量(仅仅当include Top等于True时使用)
	# classifier_activation:分类器的激活函数,默认为softmax(多类别分类)

RETURNs:
	keras.model instance
'''
tf.keras.applications.VGG16(
    include_top=True, weights='imagenet', input_tensor=None,
    input_shape=None, pooling=None, classes=1000,
    classifier_activation='softmax'
)

2.2.2 如何使用预训练的模型

在看了上方模型对象的构造函数后,相信大家已经注意到了其中几个重要的参数:
inclue_top / weights / classes

  • 那么要进行迁移学习,应该如何选择参数呢?
    示例代码:
from tensorflow.keras.applications.vgg16 import VGG16
from tensorflow.keras.applications.vgg16 import preprocess_input

'''注意下面几个变量根据需要修改!!!'''
IMG_SIZE = (160,160) #此处为自己设置的图片的SIZE 
IMG_SHAPE = IMG_SIZE + (3,) # 根据设置的图片形状得到 —— (160, 160, 3)
NUM_CLASSES = 3 # 此处为需要进行分类的类别数量
BATCH_SIZE = 32 # 一个BATCH的大小


'''构建预训练模型'''
base_model = VGG16(input_shape=IMG_SHAPE ,include_top=False, weights='imagenet')
base_model.trainable = False # 固定所有预训练模型层的参数
# Let's take a look at the base model architecture
base_model.summary()
  • 解释
    • include_top = False: 进行迁移学习,无需使用神经网络最上方的全连接层(详见上方1.2处解释),这是针对ImageNet竞赛的1000种日常对象预先训练好的网络权重。而我们应该添加新的全连接层,并训练
    • weights = ‘imagenet’ : 使用在imagenet上预训练的模型

这样就实现了只使用神经网络中用于特征提取的Convolution Layer,而不使用其预先设置好的顶层输出层。

2.2.3 添加新的分类器 & 改变输入层

GlobalAveragePooling2D: 将AxBxC张量转换后输出为NxM张量, N为图片数量,M为每张图片特征向量的维度。

除了添加新的全连接层,还有一步需要 注意!!!

由于我们使用的是预训练的模型,而预训练模型都有自己独特的输入数据的要求,因此需要我们将读入的数据转换成所要求的格式。万幸Tensorflow提供了API帮助我们实现🌈

from tf.keras.layers import GlobalAveragePooling2D
from tensorflow.keras.layers import Dense, Dropout, Flatten
from tensorflow.keras import Model
from tensorflow.keras.applications.vgg16 import preprocess_input #提供的API,注意与自己选择的模型相匹配,此处为vgg16

def add_input_top_model(base_model, class_num, input_shape):
    '''向最后一个卷积网络添加卷积层&添加输入层保证输入数据的shape符合模型要求
	  Args:
	    base_model: keras model excluding top
	    class_num: number of classes
	    input_shaope: shape of input image
	  Returns:
	    添加了全连接层的神经网络
	'''
	preprocessinput = preprocess_input
    
    inputs = tf.keras.Input(shape=input_shape)
    x = preprocessinput(inputs)
    x = base_model(x, training=False)
    x = GlobalAveragePooling2D()(x)
    # 若为2分类则
    if class_num == 2:
        outputs = Dense(1)(x) #logit
    else:
        outputs = Dense(class_num, activation='softmax')(x)
    model = Model(inputs=inputs, outputs=outputs)
    return model

!!!如果你懒得自己训练新的全连接输出层,也可以像下方这样!!!
虽然这样就不知道算不算是迁移学习了…😂
示例代码:

from tensorflow.keras.applications.vgg16 import VGG16
from tensorflow.keras.applications.vgg16 import preprocess_input

final_model = VGG16(include_top=True, weights='imagenet', classes= <numebr_of_classes realted to your task>)
# Let's take a look at the base model architecture
final_model.summary()

2.2.4 Compile the model

设置模型的Optimizer, Loss, Metrics等
这里应该按照项目的需要进行设置

from tensorflow.keras.optimizers import Adam
from tensorflow.keras.losses import BinaryCrossentropy


def model_compile(model, learning_rate=0.001):
    class_num = model.output.shape[1]
    if class_num == 2:
        #如果是二分类模型
        model.compile(optimizer=Adam(learning_rate=learning_rate),
              loss=BinaryCrossentropy(from_logits=True),
              metrics=['accuracy'])
    else:
        model.compile(optimizer=Adam(learning_rate=learning_rate), 
                      loss='categorical_crossentropy', 
                      metrics=['accuracy'])
    return model

**至此就完成了模型的构建!!!**是不是挺简单的呢?😉

2.3 处理数据🌕

模型构建好了,但是说实话还是远远不够的,如果没办法将数据处理成所需要的格式,就无法训练模型
鉴于此篇博客主要介绍模型构建,所以处理数据方面就简单上代码带过…
实际上,数据的预处理往往是十分重要的,在后续博客中,我会介绍一下图像增强的方法

  • 读入图片数据(使用tensorflow.keras.preprocessing.image_dataset_from_directory

  • 使用以下方式读入图片数据需要保证你的文件夹格式为:

    	main_directory/
    	...class_a/
    	......a_image_1.jpg
    	......a_image_2.jpg
    	...class_b/
    	......b_image_1.jpg
    	......b_image_2.jpg
    
  • 读入数据

    from tensorflow.keras.preprocessing import image_dataset_from_directory
    
    
    #注意修改路径(为父级路径)
    train_dataset = image_dataset_from_directory(train_dir,
    											shuffle=True,
    											batch_size=BATCH_SIZE,
    											image_size=IMG_SIZE)
    #注意修改路径
    test_dataset = image_dataset_from_directory(test_dir,
    											shuffle=True,
    											batch_size=BATCH_SIZE,
    											image_size=IMG_SIZE)
    '''当然也可以先读入全部数据,再进行train_test_split;也可以再切分一个validation set'''
    
    
  • OK,现在可以进行模型训练了

    history = final_model.fit(train_dataset,
                    epochs=100)
    # 顺带提一下可以保存模型到指定路径: model.save(save_path)
    

    后面使用模型进行预测之类的就不用我来教大家啦(预测的时候读入模型也要记得设置为当前设置的输入哦)~

2.4 迁移学习完整代码🌖

from tensorflow.keras.applications.vgg16 import VGG16
from tensorflow.keras.applications.vgg16 import preprocess_input
from tensorflow.keras.layers import GlobalAveragePooling2D
from tensorflow.keras.layers import Dense, Dropout, Flatten
from tensorflow.keras import Model
from tensorflow.keras.optimizers import Adam
from tensorflow.keras.losses import BinaryCrossentropy
from tensorflow.keras.preprocessing import image_dataset_from_directory

import matplotlib.pyplot as plt



def add_input_top_model(base_model, class_num, input_shape):
    '''向最后一个卷积网络添加卷积层&添加输入层保证输入数据的shape符合模型
	  Args:
	    base_model: keras model excluding top
	    class_num: number of classes
	    input_shaope: shape of input image
	  Returns:
	    添加了全连接层的神经网络
	'''
    preprocessinput = preprocess_input
    
    inputs = tf.keras.Input(shape=input_shape)
    x = preprocessinput(inputs)
    x = base_model(x, training=False)
    x = GlobalAveragePooling2D()(x)
    # 若为2分类则
    if class_num == 2:
        outputs = Dense(1)(x) #logit
    else:
        outputs = Dense(class_num, activation='softmax')(x)
    model = Model(inputs=inputs, outputs=outputs)
    return model


'''这里应该按照项目的需要进行设置'''
def model_compile(model, learning_rate=0.001):
	'''模型compile
	  Args:
	    model: keras model (added new top layer)
	    learning_rate: learning rate
	  Returns:
	   	设置完成了optimizer,loss和评估metric的模型
    '''
    class_num = model.output.shape[1]
    if class_num == 2:
        #如果是二分类模型
        model.compile(optimizer=Adam(learning_rate=learning_rate),
              loss=BinaryCrossentropy(from_logits=True),
              metrics=['accuracy'])
    else:
        model.compile(optimizer=Adam(learning_rate=learning_rate), 
                      loss='categorical_crossentropy', 
                      metrics=['accuracy'])
    return model


'''一. 构建模型'''
'''注意下面几个变量根据需要修改!!!'''
IMG_SIZE = (224,224) #此处为自己设置的图片的SIZE 
IMG_SHAPE = IMG_SIZE + (3,) # 根据设置的图片形状得到 —— (160, 160, 3)
NUM_CLASSES = 2 # 此处为需要进行分类的类别数量
BATCH_SIZE = 32 # 一个BATCH的大小

'''1.1 构建预训练模型'''
print("BASE MODE:")
base_model = VGG16(input_shape=IMG_SHAPE ,include_top=False, weights='imagenet')
base_model.trainable = False # 固定所有预训练模型层的参数
# Let's take a look at the base model architecture
base_model.summary()

'''1.2 添加顶层分类器 & 输入层'''
print("ADD CLS TOP LAYER & INPUT LAYER:")
final_model = add_input_top_model(base_model, NUM_CLASSES, IMG_SHAPE)
final_model.summary()

'''1.3 Compile'''
model = model_compile(final_model)
model.summary()


'''二. 读入数据'''
#注意修改路径
train_dataset = image_dataset_from_directory(train_path,
											shuffle=True,
											batch_size=BATCH_SIZE,
											image_size=IMG_SIZE)
#注意修改路径
# test_dataset = image_dataset_from_directory(test_dir,
#											shuffle=True,
#											batch_size=BATCH_SIZE,
#											image_size=IMG_SIZE)
#注意修改路径
# validation_dataset = image_dataset_from_directory(validation_dir,
#											shuffle=True,
#											batch_size=BATCH_SIZE,
#											image_size=IMG_SIZE)

'''三. 训练模型'''
# 可以添加EarlyStopping——param: callback=[]
# 若划分了validationset,在fit时记得添加_ param: validation_data=
history = model.fit(train_dataset,
                    epochs=100)


'''四. Learning Curve'''
# acc = history.history['accuracy']
# val_acc = history.history['val_accuracy']

# loss = history.history['loss']
# val_loss = history.history['val_loss']

# plt.figure(figsize=(8, 8))
# plt.subplot(2, 1, 1)
# plt.plot(acc, label='Training Accuracy')
# plt.plot(val_acc, label='Validation Accuracy')
# plt.legend(loc='lower right')
# plt.ylabel('Accuracy')
# plt.ylim([min(plt.ylim()),1])
# plt.title('Training and Validation Accuracy')

# plt.subplot(2, 1, 2)
# plt.plot(loss, label='Training Loss')
# plt.plot(val_loss, label='Validation Loss')
# plt.legend(loc='upper right')
# plt.ylabel('Cross Entropy')
# plt.ylim([0,1.0])
# plt.title('Training and Validation Loss')
# plt.xlabel('epoch')
# plt.show()

OK!迁移学习已经完成了!是不是挺简单的~🌝

三、微调简介

3.1 微调是什么?(What is fine-tuning?)🏀

FIne-tune能够通过在训练你自己构建的分类器的同时,重新训练预训练模型的顶层,得到新的权重。

这样做的好处也很明显——得到了更加适合于你现有数据集的权重,那么模型的效果相对而言也会有所提升。

3.2 微调的特点?(The characteristic?)🏐

微调实际上是迁移学习的一种实现方法。但是,相较于上文所讨论的方法,微调有以下的特点

  • 不是简单freeze原有CNN网络的除了底层以外的所有层,不对预训练模型的权重进行任何更改(上文所讨论的方法)。微调需要调整原有神经网络的权重,所以我们需要对预训练模型中的一部分顶层进行重新的训练,得到更加适合现有数据集的权重。
  • 一般来说,不建议大家重新训练底层的卷积层ConvLayer,因为在一个CNN模型中,这个层的位置越高,就是训练该模型的数据集相关。底层的卷积层往往是实现了非常简单且通用的功能,这些功能在所有的图像上都可以使用。而层次越高,这些功能就越来越针对于所训练的数据集。
  • 微调的目标:让模型的顶层更加适用于现有的新的数据集,而不是彻底改变预训练模型的全部权重。

所以说,如果我们需要进行微调,需要进行以下步骤

  1. 需要了解预训练模型的结构,选择一个较为合适的顶部的层。
  2. 从该层向上重新训练模型的权重,该层向下冻结。
  3. 构建新的分类器,并进行训练。(与第二步同时进行)

四、微调的实现(By: tensorflow 2.0+)

4.1 了解预训练模型的结构⚽

在上文中已经介绍了如何找到预训练模型,这里我就不再重复了。
那么如何了解预训练模型的结构呢?有人可能会说:百度,google,其实不然。在代码中已经提供了一种方法让我们快速了解预训练的模型。

from tensorflow.keras.applications.vgg16 import VGG16

base_model = VGG16(input_shape=IMG_SHAPE ,include_top=False, weights='imagenet')
# 注意这一步
base_model.summary()

执行.summary()后,会输出以下信息:
VGG16模型结构
该信息中,我们可以看到当前模型的结构

4.2 冻结下层,使上层可以被训练⚾

选择其中一层 ,从该层向上可以进行训练,从该层向下不能进行训练

base_model.trainable = True
fine_tune_at = 16 #表明从第十六层开始重新进行训练
for layer in base_model.layers[:fine_tune_at]:
    layer.trainable = False

4.3 构建模型并进行训练🏈

这里的步骤与上文构建的简单Transfer-learning是相同的,只需要将上一步的模型传入之前构建的函数即可

4.4 微调完整代码

import tensorflow as tf
from tensorflow.keras.applications.vgg16 import VGG16
from tensorflow.keras.applications.vgg16 import preprocess_input
from tensorflow.keras.layers import GlobalAveragePooling2D
from tensorflow.keras.layers import Dense, Dropout, Flatten
from tensorflow.keras import Model
from tensorflow.keras.optimizers import Adam
from tensorflow.keras.losses import BinaryCrossentropy
from tensorflow.keras.preprocessing import image_dataset_from_directory
from tensorflow.keras.layers import Concatenate

import matplotlib.pyplot as plt
import numpy as np



def add_input_top_model(base_model, class_num, input_shape):
    preprocessinput = preprocess_input
    
    inputs = tf.keras.Input(shape=input_shape)
    x = preprocessinput(inputs)
    x = base_model(x)
    x = GlobalAveragePooling2D()(x)
    # 若为2分类则
    if class_num == 2:
        outputs = Dense(1)(x) #logit
    else:
        outputs = Dense(class_num, activation='softmax')(x)
    model = Model(inputs=inputs, outputs=outputs)
    return model

    
'''这里应该按照项目的需要进行设置'''
def model_compile(model, learning_rate=0.001):
    class_num = model.output.shape[1]
    if class_num == 2:
        #如果是二分类模型
        model.compile(optimizer=Adam(learning_rate=learning_rate),
              loss=BinaryCrossentropy(from_logits=True),
              metrics=['accuracy'])
    else:
        model.compile(optimizer=Adam(learning_rate=learning_rate), 
                      loss='categorical_crossentropy', 
                      metrics=['accuracy'])
    return model




'''一、 构建模型'''
'''注意下面几个变量根据需要修改!!!'''
IMG_SIZE = (224,224) #此处为自己设置的图片的SIZE 
IMG_SHAPE = IMG_SIZE + (3,) # 根据设置的图片形状得到 —— (160, 160, 3)
NUM_CLASSES = 2 # 此处为需要进行分类的类别数量
BATCH_SIZE = 32 # 一个BATCH的大小

'''1.1 构建预训练模型'''
print("BASE MODE:")
base_model = VGG16(input_shape=IMG_SHAPE, include_top=False, weights='imagenet')
# Let's take a look at the base model architecture
base_model.summary()

'''1.2 微调所需!!!'''
base_model.trainable = True
fine_tune_at = 16 #表明从第十六层开始重新进行训练
for layer in base_model.layers[:fine_tune_at]:
    layer.trainable = False
#print(len(base_model.trainable_variables))

'''1.2 添加顶层分类器 & 输入层'''
print("ADD CLS TOP LAYER & INPUT LAYER:")
final_model = add_input_top_model(base_model, NUM_CLASSES, IMG_SHAPE)
final_model.summary()
#print(len(final_model.trainable_variables))

'''1.3 Compile'''
model = model_compile(final_model)
# model.summary()


'''二. 读入数据'''
#注意修改路径
train_dataset = image_dataset_from_directory(train_path,
											shuffle=True,
											batch_size=BATCH_SIZE,
											image_size=IMG_SIZE)
#注意修改路径
test_dataset = image_dataset_from_directory(test_dir,
											shuffle=True,
											batch_size=BATCH_SIZE,
											image_size=IMG_SIZE)
#注意修改路径
# validation_dataset = image_dataset_from_directory(validation_dir,
#											shuffle=True,
#											batch_size=BATCH_SIZE,
#											image_size=IMG_SIZE)

'''三. 训练模型'''
# 可以添加EarlyStopping——param: callback=[]
# 若划分了validationset,在fit时记得添加_ param: validation_data=
history = model.fit(train_dataset,
                    epochs=100)


'''四. Learning Curve'''
# acc = history.history['accuracy']
# val_acc = history.history['val_accuracy']

# loss = history.history['loss']
# val_loss = history.history['val_loss']

# plt.figure(figsize=(8, 8))
# plt.subplot(2, 1, 1)
# plt.plot(acc, label='Training Accuracy')
# plt.plot(val_acc, label='Validation Accuracy')
# plt.legend(loc='lower right')
# plt.ylabel('Accuracy')
# plt.ylim([min(plt.ylim()),1])
# plt.title('Training and Validation Accuracy')

# plt.subplot(2, 1, 2)
# plt.plot(loss, label='Training Loss')
# plt.plot(val_loss, label='Validation Loss')
# plt.legend(loc='upper right')
# plt.ylabel('Cross Entropy')
# plt.ylim([0,1.0])
# plt.title('Training and Validation Loss')
# plt.xlabel('epoch')
# plt.show()


'''五、评价与预测'''
loss, accuracy = model.evaluate(test_dataset)
print('Test accuracy :', accuracy)

OK!微调的方式实现迁移学习已经完成了!是不是也挺简单的~🥽

推荐:Tensorflow官网介绍——迁移学习和微调: 链接

  • 10
    点赞
  • 79
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
AnyLogic是一种多模型仿真工具,可以用于建立各种系统的数字孪生模型模型构建层包括物理对象的虚拟模型,如医疗资源模型、医疗能力模型和人体健康模型,通过实时数据交互与物理实体进行交互,实现数据集成和聚合。模型管理应包括可视化流程设计、插件框架式模型设计和管理扩展模型以及发布模型服务能力,通过算法注册、数据源管理及配套可视化工具实现模型构建。建立AnyLogic模型可以基于知识、工业机理和数据三种方式进行建模。基于知识建模要求建立专家知识库并具有行业沉淀,模型较简单但精度、及时性和可迁移性较差。基于机理建模可以覆盖较大的变量空间,具有可解释性,但需要大量参数和复杂的计算。基于数据建模可以获得较高的模型精度和动态更新能力,但对数据数量、质量和精度有较高的要求,无法解释模型&lt;span class=&quot;em&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;em&quot;&gt;2&lt;/span&gt;&lt;span class=&quot;em&quot;&gt;3&lt;/span&gt; #### 引用[.reference_title] - *1* *2* *3* [一看懂数字孪生,工信部权威白皮书](https://blog.csdn.net/cf2SudS8x8F0v/article/details/109733143)[target=&quot;_blank&quot; data-report-click={&quot;spm&quot;:&quot;1018.2226.3001.9630&quot;,&quot;extra&quot;:{&quot;utm_source&quot;:&quot;vip_chatgpt_common_search_pc_result&quot;,&quot;utm_medium&quot;:&quot;distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v92^chatsearchT0_1&quot;}}] [.reference_item style=&quot;max-width: 100%&quot;] [ .reference_list ]

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值