ResNet论文阅读

ResNet


参考资料:https://blog.csdn.net/qq_39297053/article/details/130675058?spm=1001.2014.3001.5502
论文题目:Deep Residual Learning for Image Recognition
ResNet论文下载链接: https://arxiv.org/pdf/1512.03385

单词
重要的

residual learning framework 残差学习框架

vanishing/exploding gradients 消失/爆炸梯度

hamper convergence from the beginning 从一开始就阻碍融合(收敛)

intermediate normalization layers 中间归一化层

backpropagation 反向传播

不重要的

explicitly 明确地

empirical 凭经验的

notorious problem 臭名昭著的问题

optimize 优化

摘要

Deeper neural networks are more difficult to train. We present a residual learning framework to ease the training of networks that are substantially deeper than those used previously. We explicitly reformulate the layers as learning residual functions with reference to the layer inputs, instead of learning unreferenced functions. We provide comprehensive empirical evidence showing that these residual networks are easier to optimize, and can gain accuracy from considerably increased depth. On the ImageNet dataset we evaluate residual nets with a depth of up to 152 layers—8×deeper than VGG nets [41] but still having lower complexity. An ensemble of these residual nets achieves 3.57% error on the ImageNet test set. This result won the 1st place on the ILSVRC 2015 classification task. We also present analysis on CIFAR-10 with 100 and 1000 layers. The depth of representations is of central importance for many visual recognition tasks. Solely due to our extremely deep representations, we obtain a 28% relative improvement on the COCO object detection dataset. Deep residual nets are foundations of our submissions to ILSVRC & COCO 2015 competitions1, where we also won the 1st places on the tasks of ImageNet detection, ImageNet localization, COCO detection, and COCO segmentation.

更深层次的神经网络更难训练。我们提出了一个残差学习框架,以简化比以前使用的网络更深的网络训练。我们明确地将层重新表示为参考层输入的学习残差函数,而不是学习未引用的函数。我们提供了全面的经验证据,表明这些残差网络更容易优化,并且可以通过显着增加的深度来获得准确性。在 ImageNet 数据集上,我们评估深度高达 152 层的残差网络,比 VGG 网络 [41] 深 8 倍,但复杂度仍然较低。这些残差网络的集合在 ImageNet 测试集上实现了 3.57% 的误差。该结果在 ILSVRC 2015 分类任务中获得第一名。我们还对 100 层和 1000 层的 CIFAR-10 进行了分析。表示的深度对于许多视觉识别任务至关重要。仅仅由于我们极深的表示,我们在 COCO 目标检测数据集上获得了 28% 的相对改进。深度残差网络是我们提交 ILSVRC 和 COCO 2015 竞赛的基础,我们还在 ImageNet 检测、ImageNet 本地化、COCO 检测和 COCO 分割任务上获得了第一名。

深度学习网络退化问题

从经验来看,网络的深度对模型的性能至关重要,当增加网络层数后,网络可以进行更加复杂的特征提取,所以当模型更深时理论上可以取得更好的结果,例如VGG网络就证明了更深的网络可以带来更好的效果。但是更深的网络其性能一定会更好吗?

实验发现深度网络出现了退化问题(Degradation Problem):当网络深度持续增加时,网络准确度出现饱和,甚至出现下降。下图节选自ResNet原文,不管是在训练阶段还测试证阶段,56层的网络比20层网络错误率更高,效果更差。究竟是什么原因导致的这一问题?

论文引用:

  • When deeper networks are able to start converging, a degradation problem has been exposed: with the network depth increasing, accuracy gets saturated (which might be unsurprising) and then degrades rapidly
  • 当更深层的网络能够开始收敛时,退化问题就暴露出来了:随着网络深度的增加,准确度会饱和(这可能并不令人惊讶),然后迅速退化

image-20230625172834150

首先想到的是神经网络中的一个普遍问题:过拟合问题。但是,过拟合的主要表现是在训练阶段效果良好,但在测试阶段表现欠佳,这与上图情况是不相符的。 因此,造成网络性能下降的原因可能是梯度爆炸或梯度消失

论文引用

  • Unexpectedly, such degradation is not caused by overfitting, and adding more layers to a suitably deep model leads to higher training error, as reported in [11, 42] and thoroughly verified by our experiments. Fig. 1 shows a typical example

  • 出乎意料的是,这种退化并不是由过拟合引起的,在适当深度的模型中增加更多的层会导致更高的训练误差,如[11,42]所报道的,并被我们的实验彻底验证。图1显示了一个典型的例子

为了理解梯度不稳定性的原因,首先回顾一下反向传播的知识。
反向传播结果的数值大小不仅取决于导数计算公式,同时也取决于输入数据的大小。因为神经网络的计算本质其实是矩阵的连乘,当计算图每次输入的值都大于1,那么经过很多层回传,梯度将不可避免地呈几何倍数增长,直到不可计量,这就是梯度爆炸现象。反之,如果每个阶段的输入值都小于1,则梯度将会几何级别地下降,最终可能会趋近于0,这就是梯度消失。由于目前神经网络的参数更新基于反向传播,因此梯度不稳定性看似是一个非常重要的问题。

然而,事实并非如此简单。我们现在无论用PyTorch还是Tensorflow,都会自然而然地加上Bacth Normalization,而BN的作用本质上也是控制每层输入的模值,因此梯度的爆炸/消失现象理论上应该在很早就被解决了,至少解决了大部分。

论文引用

  • Driven by the significance of depth, a question arises: Is learning better networks as easy as stacking more layers?An obstacle to answering this question was the notorious problem of vanishing/exploding gradients , which hamper convergence from the beginning. This problem, however, has been largely addressed by normalized initialization and intermediate normalization layers , which enable networks with tens of layers to start converging for stochastic gradient descent (SGD) with backpropagation
  • 在深度的重要性的驱使下,一个问题出现了:学习更好的网络就像堆叠更多的层一样简单吗?回答这个问题的一个障碍是臭名昭著的梯度消失/爆炸问题,它从一开始就阻碍了收敛。然而,这个问题已经通过规范化初始化和中间规范化层得到了很大程度的解决,这使得具有数十层的网络能够开始收敛随机梯度下降(SGD),并具有反向传播

模型性能退化的现象在直觉上似乎违反了常理。通常,我们认为当模型层数增加时,其性能应该提升。例如,如果一个浅层网络已经能达到良好的效果,那么即使我们在其上添加更多层,即使这些额外的层并不进行任何操作,模型的性能理论上也不应该下降。然而,实际情况却并非如此。实际上,“什么也不做”恰恰是现有神经网络最难实现的一点。

论文引用:

  • There exists a solution by constructionto the deeper model: the added layers are identity mapping, and the other layers are copied from the learned shallower model. The existence of this constructed solution indicates that a deeper model should produce no higher training error than its shallower counterpart.

  • 对于更深层次的模型,存在一个通过构造的解决方案:添加的层是身份映射,其他层是从学习到的浅层次模型复制的。这个构造解的存在表明,较深的模型应该不会比较浅的模型产生更高的训练误差。

  • To the extreme, if an identity mapping were optimal, it would be easier to push the residual to zero than to fit an identity mapping by a stack of nonlinear layers.

  • 极端情况下,如果身份映射是最优的,则将残差推到零比通过一堆非线性层拟合恒等映射更容易。

  • The degradation problem suggests that the solvers might have difficulties in approximating identity mappings by multiple nonlinear layers

  • 退化问题表明求解器在通过多个非线性层逼近恒等映射时可能会遇到困难。

残差网络

ResNet模型解决网络退化问题的方法是设计了残差连接,最简单的残差如下图所示。图中右侧的曲线叫作残差连接(Residual Connection),通过跳接在激活函数前,将上一层(或几层)之前的输出与本层计算的输出相加,将求和的结果输入到激活函数中作为本层的输出。

顺带一提,这里一个Block中必须至少含有两个层,否则网络将退化成一个简单的神经网络: y = ( w x + b ) + x 等价于 y = w x + ( b + x ) y=(wx+b)+x等价于y=wx+(b+x) y=(wx+b)+x等价于y=wx+(b+x)

image-20230625173846058

前面分析得出,如果深层网络后面的层都是是恒等映射,那么模型就可以等价转化为一个浅层网络。那现在的问题就是如何得到恒等映射了。事实上,已有的神经网络很难拟合潜在的恒等映射函数H(x) = x。但如果把网络设计为H(x) = F(x) + x,即直接把恒等映射作为网络要学习并输出的一部分。就可以把问题转化为学习一个残差函数F(x) = H(x) - x

所谓残差连接指的就是将浅层的输出和深层的输出求和作为下一阶段的输入,这样做的结果就是本来这一层权重需要学习是一个对 x 到 H(x) 的映射。那使用残差链接以后,权重需要学习的映射变成了 从x -> H(x) - x 。这样在反向传播的过程中,小损失的梯度更容易抵达浅层的神经元。其实这个和循环神经网络LSTM中控制门的原理也是一样的。

ResNet的网络结构

ResNet网络是参考了VGG19网络,在其基础上进行了修改,并通过短路机制加入了上图所示的残差链接。除此之外,变化主要体现在ResNet直接使用stride=2的卷积做下采样(取代了VGG中的池化)

论文引用:

  • We perform downsampling directly by convolutional layers that have a stride of 2.

解释:

k=3,s=1,p=1,则输出尺寸和输入尺寸一样,所以此时把s改为2就是2倍下采样

=(W-k+2p)/s+1,带入k=3,s=1,p=1,(W-1)/1+1=W

并且用global average pool层替换了全连接层(这样可以接收不同尺寸的输入图像),另外模型层次明显变深。相似之处是两者都是通过堆叠3X3的卷积进行特征提取。

ResNet的一个重要设计原则是:当feature map大小降低一半时,feature map的数量增加一倍,这一定程度上减轻了因减少特征图尺寸而带来的信息损失(换句话说,将输入信息的特征从空间维度提取到通道维度上)

从下图中可以看到,ResNet相比普通卷积网络每两层间增加了残差链接,其中虚线表示feature map数量发生了改变。

image-20230626095138312

在ResNet原论文中,作者给出了五个不同层次的模型结构,分别是18层,34层,50层,101层,152层。上图所示的是34层的模型结构。下图给出所有模型的结构参数:

image-20230626095255094

值得注意的是: 50层,101层和152层使用的残差模块与之前介绍的不同。主要原因是深层次的网络中参数量太大,为了减少参数,在3X3卷积前先通过1X1卷积对channel维度进行降维

image-20230626095531235

如上图所示:左图是浅层模型用的残差结构;右图是深层模型用的残差结构。

注意:对于残差,只有当输入和输出维度一致时,才可以直接将输入加到输出上。但是当维度不一致时,不能直接相加。这时可以采用新的映射(projection shortcut),比如一般采用1x1的卷积对残差传递的信息做维度调整。

代码
import torch.nn as nn
import torch

##这个类对应论文中的figure 2
##也可以是figure 5左边的图
class BasicBlock(nn.Module):
    expansion = 1 

    def __init__(self, in_channel, out_channel, stride=1, downsample=None, **kwargs):
        super(BasicBlock, self).__init__()
        self.conv1 = nn.Conv2d(in_channels=in_channel, out_channels=out_channel, kernel_size=3, stride=stride, padding=1, bias=False)
        self.bn1 = nn.BatchNorm2d(out_channel)
        self.relu = nn.ReLU()
        self.conv2 = nn.Conv2d(in_channels=out_channel, out_channels=out_channel, kernel_size=3, stride=1, padding=1, bias=False)
        self.bn2 = nn.BatchNorm2d(out_channel)
        self.downsample = downsample ##下采样 就是论文中虚线的部分 只有在两个stage之间才有这个

    def forward(self, x):
        identity = x
        if self.downsample is not None:
            identity = self.downsample(x)

        out = self.conv1(x)
        out = self.bn1(out)
        out = self.relu(out)

        out = self.conv2(out)
        out = self.bn2(out)

        out += identity
        out = self.relu(out)

        return out


##这个类对应figure 5右边的图
class Bottleneck(nn.Module):
    """
    注意:原论文中,在虚线残差结构的主分支上,第一个1x1卷积层的步距是2,第二个3x3卷积层步距是1。
    但在pytorch官方实现过程中是第一个1x1卷积层的步距是1,第二个3x3卷积层步距是2,
    这么做的好处是能够在top1上提升大概0.5%的准确率。
    可参考Resnet v1.5 https://ngc.nvidia.com/catalog/model-scripts/nvidia:resnet_50_v1_5_for_pytorch
    """
    expansion = 4##扩张因子 从64升到了256所以???

    def __init__(self, in_channel, out_channel, stride=1, downsample=None,
                 groups=1, width_per_group=64):
        super(Bottleneck, self).__init__()

        width = int(out_channel * (width_per_group / 64.)) * groups

        self.conv1 = nn.Conv2d(in_channels=in_channel, out_channels=width,  kernel_size=1, stride=1, bias=False)  # squeeze channels
        self.bn1 = nn.BatchNorm2d(width)
        # -----------------------------------------
        self.conv2 = nn.Conv2d(in_channels=width, out_channels=width, groups=groups, kernel_size=3, stride=stride, bias=False, padding=1)
        self.bn2 = nn.BatchNorm2d(width)
        # -----------------------------------------
        self.conv3 = nn.Conv2d(in_channels=width, out_channels=out_channel*self.expansion, kernel_size=1, stride=1, bias=False)  # unsqueeze channels
        self.bn3 = nn.BatchNorm2d(out_channel*self.expansion)
        self.relu = nn.ReLU(inplace=True)
        self.downsample = downsample

    def forward(self, x):
        identity = x
        if self.downsample is not None:
            identity = self.downsample(x)

        out = self.conv1(x)
        out = self.bn1(out)
        out = self.relu(out)

        out = self.conv2(out)
        out = self.bn2(out)
        out = self.relu(out)

        out = self.conv3(out)
        out = self.bn3(out)

        out += identity
        out = self.relu(out)

        return out


class ResNet(nn.Module):

    def __init__(self,
                 block,
                 blocks_num,
                 num_classes=1000,
                 include_top=True,
                 groups=1,
                 width_per_group=64):
        super(ResNet, self).__init__()
        self.include_top = include_top
        self.in_channel = 64

        self.groups = groups
        self.width_per_group = width_per_group
        ##对应论文中的table1,我输入的图片是彩色的,所以通道数是3,输出通道数64
        self.conv1 = nn.Conv2d(3, self.in_channel, kernel_size=7, stride=2,  padding=3, bias=False)
        self.bn1 = nn.BatchNorm2d(self.in_channel)
        self.relu = nn.ReLU(inplace=True)
        self.maxpool = nn.MaxPool2d(kernel_size=3, stride=2, padding=1) #k=3,s=1,p=1,则输出尺寸和输入尺寸一样,所以此时把s改为2就是2倍下采样
        ## =(W-k+2p)/s+1,带入k=3,s=1,p=1,得(W-1)/1+1=W

        ##因为第一层没有做2倍下采样,所以stride=1,其他层都为2
        self.layer1 = self._make_layer(block, 64, blocks_num[0])
        self.layer2 = self._make_layer(block, 128, blocks_num[1], stride=2)
        self.layer3 = self._make_layer(block, 256, blocks_num[2], stride=2)
        self.layer4 = self._make_layer(block, 512, blocks_num[3], stride=2)
        if self.include_top:
            self.avgpool = nn.AdaptiveAvgPool2d((1, 1))  # output size = (1, 1)
            self.fc = nn.Linear(512 * block.expansion, num_classes)

        for m in self.modules():
            if isinstance(m, nn.Conv2d):
                nn.init.kaiming_normal_(m.weight, mode='fan_out', nonlinearity='relu')

    def _make_layer(self, block, channel, block_num, stride=1):
        downsample = None
        if stride != 1 or self.in_channel != channel * block.expansion:
            downsample = nn.Sequential(
                nn.Conv2d(self.in_channel, channel * block.expansion, kernel_size=1, stride=stride, bias=False),
                nn.BatchNorm2d(channel * block.expansion))

        layers = []
        layers.append(block(self.in_channel,
                            channel,
                            downsample=downsample,
                            stride=stride,
                            groups=self.groups,
                            width_per_group=self.width_per_group))
        self.in_channel = channel * block.expansion

        for _ in range(1, block_num):
            layers.append(block(self.in_channel,
                                channel,
                                groups=self.groups,
                                width_per_group=self.width_per_group))

        return nn.Sequential(*layers)

    def forward(self, x):
        x = self.conv1(x)
        x = self.bn1(x)
        x = self.relu(x)
        x = self.maxpool(x)

        x = self.layer1(x)
        x = self.layer2(x)
        x = self.layer3(x)
        x = self.layer4(x)

        if self.include_top:
            x = self.avgpool(x)
            x = torch.flatten(x, 1)
            x = self.fc(x)

        return x

# # resnet34  pre-train parameters https://download.pytorch.org/models/resnet34-333f7ec4.pth
# def resnet_samll(num_classes=1000, include_top=True):
   
#     return ResNet(BasicBlock, [3, 4, 6, 3], num_classes=num_classes, include_top=include_top)

# # resnet50  pre-train parameters https://download.pytorch.org/models/resnet50-19c8e357.pth
# def resnet(num_classes=1000, include_top=True): 
#     return ResNet(Bottleneck, [3, 4, 6, 3], num_classes=num_classes, include_top=include_top)

# # resnet101 pre-train parameters https://download.pytorch.org/models/resnet101-5d3b4d8f.pth
# def resnet_big(num_classes=1000, include_top=True):
#     return ResNet(Bottleneck, [3, 4, 23, 3], num_classes=num_classes, include_top=include_top)

# # resneXt pre-train parameters https://download.pytorch.org/models/resnext50_32x4d-7cdf4587.pth
# def resnext(num_classes=1000, include_top=True): 
#     groups = 32
#     width_per_group = 4
#     return ResNet(Bottleneck, [3, 4, 6, 3],
#                   num_classes=num_classes,
#                   include_top=include_top,
#                   groups=groups,
#                   width_per_group=width_per_group)

# # resneXt_big pre-train parameters https://download.pytorch.org/models/resnext101_32x8d-8ba56ff5.pth
# def resnext_big(num_classes=1000, include_top=True): 
#     groups = 32
#     width_per_group = 8
#     return ResNet(Bottleneck, [3, 4, 23, 3],
#                   num_classes=num_classes,
#                   include_top=include_top,
#                   groups=groups,
#                   width_per_group=width_per_group)

def resnet34(num_classes=1000, include_top=True):
    # https://download.pytorch.org/models/resnet34-333f7ec4.pth
    return ResNet(BasicBlock, [3, 4, 6, 3], num_classes=num_classes, include_top=include_top)


def resnet50(num_classes=1000, include_top=True):
    # https://download.pytorch.org/models/resnet50-19c8e357.pth
    return ResNet(Bottleneck, [3, 4, 6, 3], num_classes=num_classes, include_top=include_top)


def resnet101(num_classes=1000, include_top=True):
    # https://download.pytorch.org/models/resnet101-5d3b4d8f.pth
    return ResNet(Bottleneck, [3, 4, 23, 3], num_classes=num_classes, include_top=include_top)


def resnext50_32x4d(num_classes=1000, include_top=True):
    # https://download.pytorch.org/models/resnext50_32x4d-7cdf4587.pth
    groups = 32
    width_per_group = 4
    return ResNet(Bottleneck, [3, 4, 6, 3],
                  num_classes=num_classes,
                  include_top=include_top,
                  groups=groups,
                  width_per_group=width_per_group)


def resnext101_32x8d(num_classes=1000, include_top=True):
    # https://download.pytorch.org/models/resnext101_32x8d-8ba56ff5.pth
    groups = 32
    width_per_group = 8
    return ResNet(Bottleneck, [3, 4, 23, 3],
                  num_classes=num_classes,
                  include_top=include_top,
                  groups=groups,
                  width_per_group=width_per_group)
残差连接的渊源

image-20230626141616569

如图所示,T门是转换门,C门是携带门,然后令C=1-T,当T=1时,显然就不经过携带门,当T=0时,不经过转换们,输出x

残差连接有效性解释

把残差网络展开, 相当与集成(ensemble)了多个神经网络, 这是一种典型的集成学习的思想(ensemble learning),如下图示:

image-20230627090632897

左边的残差网络看似是单路的网络结果,其实如果把信息前向计算的可能路径全部画出来,即右边子图所示,此时可以看作是很多单路网络集成在一起形成的多路网络结构。

另外,Deep Networks with Stochastic Depth这篇工作表明ResNet太深了,有些层是没必要的。实验方法简单解释就是通过在训练过程中随机丢掉一些层,来优化深度残差网络的训练过程。网络变得简单一点了。

具体的实现是通过Drop path:假设残差网络表达为公式y=H(x)+x ,那么Stochastic Depth Net训练时,会加入了随机变量 b(伯努利随机变量),通过y= b*H(x)+x,对 ResBlock的残差部分做了随机丢弃。

如果b = 1,则简化为原始的ResNet结构;如果b = 0,则这个ResBlock未被激活,降为恒等函数。此外,Stochastic Depth Net还使用线性衰减的生存概率原则来随机丢弃整个模型中不同深度的层级结构,如下图所示,将“线性衰减规律”应用于每层的生存概率,由于较早的层会提取低级特征,而这些低级特征会被后面的层所利用,所以这些层不应该频繁地被丢弃。最终 p 生成的规则就变成了这样:

即随着网络层数的增加,丢弃率增加

image-20230626142023160

实际上,Stochastic Depth Net的Drop path 与 AelxNet中提出的Dropout大同小异;只是两者的作用对象不同:Dropout是作用在神经元级别上的;而Drop path比较高级,其是作用在网络的层级结构上的。

ResNeXt

传统的模型优化方法主要依赖于增加网络的深度或宽度。然而,随着网络参数数量的增长,调整参数、设计网络和计算成本等问题也随之增加。当超参数过多时,往往很难保证所有参数都被优化到最佳状态。此外,传统模型训练出的网络对超参数的依赖性较强,使得模型在不同的数据集上需要频繁调整参数,这对模型的可扩展性造成了影响。

ResNeXt模型的出现,希望在提高准确率的同时,不增加甚至降低模型复杂度,并减少超参数的数量。其使用的分组卷积方法,提供了一种新的优化思路。

实验结果表明,ResNeXt在可扩展性、简单性、模块化设计以及超参数数量上都表现出优越性。在参数数量相同的情况下,ResNeXt的表现优于其他模型。例如,ResNeXt-101的准确率与ResNet-200相当,但计算量却减少了一半。这种优化使得ResNeXt模型在深度学习领域具有重要价值。

image-20230626142413700参数量一致,但是分组卷积输出更多。

当g=c时,就是GoogLeNet V5中的Xception

当g=1时,就是普通卷积

image-20230626143121051

可以看出ResNeXt先对1×1的卷积进行了一个分组(Groups=32);注意:ResNeXt残差块经过第一个1×1卷积后的维度是32×4=128,相比原始残差块64的维度来说,是上升的。由于分组卷积可以减少很多参数量,所有这里即便维度上升了,总参数量也是下降的。

图(a)是先分组卷积再相加(add),最后加上残差链接;图(b)是先分组,再拼接(concat),再通过1X1卷积升维,最后再加上残差链接; 图(c)是先1X1升维,再分组卷积,再1X1升维。因此,在工程实践上直接采用最简单的(c)方式实现。

经过实验a,b,c三种结果的结果差不多。因此,在工程实践上直接采用最简单的(c)方式实现。

ResNeXt为什么有效

ResNext中引入cardinality,实际上只是组卷积中的Group概念换了个名字而已。想探究ResNeXt奏效的原因,其实就要思考分组卷积的奏效原因了。

众所周知,在卷积的过程中设计多个卷积核的作用是期望每个卷积核能够学到feature map可以表征不同的特征。类比一下人类,我们在思考问题的时候,最好从多个角度思考;同理,在卷积核提取特征时,我们也希望可以从不同的角度提取特征(多个卷积核就是多个不同的角度)。为啥讲这个?因为分组卷积其实就是在以另一种方式来做这件事情。

不同的组之间实际上是不同的subspace,而他们的确能学到更多样的特征表示。这一点是有迹可循的,可以追溯到AlexNet提出的时候(AlexNet把网络分成两组,其实这样做的目的是硬件不行,通过分组以减少显存,但是却发现下面这种比较神奇的事情,也算是无心为之的发现),两组sub-networks一组倾向于学习黑白的信息(下图上半部分),而另一组倾向于学习到彩色的信息 (下图下半部分)。

image-20230627092352642

AlexNet 论文中没有明确指出这是卷积组造成的这个现象,即不同的组可以学习更多样的不同特征表示。甚至连组卷积这个概念也没有提出来。其实,现在看来,相比分组卷积带来的参数量减少这一好处,分组卷积可以学习更多元的特征表示这一好处显得更加重要;因为,后面的研究发现,分组卷积即便能减少大量参数,但是其在硬件上的实际运行时间并没有很快。

  • 3
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值