基于卷积的图像分类识别(六):DenseNet & FractalNet

系列文章目录

本专栏介绍基于深度学习进行图像识别的经典和前沿模型,将持续更新,包括不仅限于:AlexNet, ZFNet,VGG,GoogLeNet,ResNet,DenseNet,SENet,MobileNet,ShuffleNet,EifficientNet,Vision Transformer,Swin Transformer,Visual Attention Network,ConvNeXt, MLP-Mixer,As-MLP,ConvMixer,MetaFormer



DenseNet论文名称:Densely Connected Convolutional Networks
DenseNet论文下载链接:https://arxiv.org/pdf/1608.06993.pdf
DenseNetpytorch代码实现:https://github.com/Arwin-Yu/Deep-Learning-Classification-Models-Based-CNN-or-Attention

FractalNet论文名称:FractalNet: Ultra-Deep Neural Networks without Residuals
FractalNet论文下载链接:https://arxiv.org/pdf/1605.07648

前言

作为CVPR2017年的Best Paper, DenseNet脱离了通过加深网络层数(VGG,ResNet)和加宽网络结构(GoogLeNet)来提升网络性能的定式思维, 从特征的角度考虑, 通过特征重用和旁路(Bypass)设置,既大幅度减少了网络的参数量,又在一定程度上缓解了梯度弥散问题的产生. 结合信息流和特征复用的假设, DenseNet当之无愧成为2017年计算机视觉顶会的年度最佳论文。另外,本文还对FractalNet进行了介绍,FractalNet与DenseNet的设计思想有异曲同工之妙。

先列下DenseNet的几个优点,感受下它的强大:

  • 1、减轻了vanishing-gradient(梯度消失)
  • 2、加强了feature的传递,更有效地利用了不同层的feature
  • 3、网络更易于训练,并具有一定的正则效果.
  • 4、因为整个网络并不深,所以一定程度上较少了参数数量

一、Motivation

卷积神经网络在沉睡了近20年后, 如今成为了深度学习方向最主要的网络结构之一. 从一开始的只有五层结构的LeNet, 到后来拥有19层结构的VGG【传送门】, 再到首次跨越100层网络的Highway Networks与1000层的ResNet, 网络层数的加深成为CNN发展的主要方向之一; 另一个方向则是以GoogLeNet【传送门】为代表的加深网络宽度.

随着CNN网络层数的不断增加,gradient vanishing和model degradation问题出现在了人们面前, BatchNormalization的广泛使用在一定程度上缓解了gradient vanishing的问题,而ResNet和Highway Networks通过构造旁路,进一步减少了gradient vanishing和model degradation的产生. Fractal Nets通过将不同深度的网络并行化, 在获得了深度的同时保证了梯度的传播, Stochastic Deep Network通过对网络中一些层进行失活,既证明了ResNet深度的冗余性,又缓解了上述问题的产生(失活操作对网络的影响与DenseNet还挺相似). 虽然这些不同的网络框架通过不同的实现加深的网络层数,但是他们都包含了相同的核心思想, 即:将不同层的feature map进行跨网络层的连接.

何恺明同学在提出ResNet时做出了这样的假设: 若某一较深的网络多出另一较浅网络的若干层,且这些层有能力学习到恒等映射, 那么这一较深网络训练得到的模型性能一定不会弱于该浅层网络. 通俗的说就是如果对某一网络中增添一些可以学到恒等映射的层组成新的网路, 那么最差的结果也是新网络中的这些层在训练后成为恒等映射而不会影响原网络的性能. 同样DenseNet在提出时也做过假设: 与其多次学习冗余的特征,特征复用是一种更好的特征提取方式.

二、Model Architecture

假设输入为一个图片 x 0 x_0 x0, 经过一个 L \mathrm{L} L 层的神经网络, 第 1 层的特征输出记作 x 1 x_1 x1 。 那么残差连接的公式如下所示。
x 1 = H 1 ( x 1 − 1 ) + x 1 − 1 x_1=H_1\left(x_{1-1}\right)+x_{1-1} x1=H1(x11)+x11
对于 ResNet 而言, 1 层的输出是 1-1 层的输出加上对 1-1 层输出的非线性变换。 对与 DensNet 而言, I 层的输出是之前所有层的输出集合, 公式如下所示。
x 1 = H 1 ( [ x 0 , x 1 , … , x l − 1 ] ) \boldsymbol{x}_{\mathbf{1}}=H_1\left(\left[x_0, x_1, \ldots, x_{l-1}\right]\right) x1=H1([x0,x1,,xl1])
其中 [ ] [ ] []代表concatenation(拼接),既将第0层 到 l-1 层的所有输出feature map在channel维度上组合在一起. 这里所用到的非线性变换H为BN+ReLU+ Conv(3×3)的组合。所以从这两个公式就能看出DenseNet和ResNet在本质上的区别,下面放一张DenseBlock的图片帮助理解公式:

2.1 DenseBlock

DenseNet Block
虽然这些残差模块中的连线很多,但是它们代表的操作只是一个空间上的拼接,并不是实际上的加减乘除运算,所以DenseNet相比传统的卷积神经网络可训练参数量更少。但是,为了在网络深层实现拼接操作,必须把之前的计算结果保存下来,这就比较占内存了。这是DenseNet的一大缺点。其代码实现如下所示。

1.class _DenseBlock(nn.ModuleDict):     
2.    def __init__(self, num_layers, input_c, bn_size, growth_rate,  drop_rate):    
3.        super(_DenseBlock, self).__init__()    
4.        for i in range(num_layers):    
5.            layer = _DenseLayer(input_c + i * growth_rate, growth_rate=growth_rate, bn_size=bn_size, drop_rate=drop_rate)    
6.            self.add_module("denselayer%d" % (i + 1), layer)    
7.    
8.    def forward(self, init_features):    
9.        features = [init_features]    
10.        for name, layer in self.items():    
11.            new_features = layer(features)    
12.            features.append(new_features)    
13.        return torch.cat(features, 1)   

2.2 Down-sampling Layer

由于在DenseNet中需要对不同层的feature map进行cat操作,所以需要不同层的feature map保持相同的feature size,这就限制了网络中Down sampling的实现.为了使用Down sampling,作者将DenseNet分为多个stage,每个stage包含多个Dense blocks, 如下图所示:

DenseNet model

在同一个Denseblock中要求特征图尺寸保持相同大小,在不同Denseblock之间设置Transition Layers实现下采样操作,具体来说,transition Layer由BN+Conv(kernel size 1×1)+average-pooling(kernel size 2×2)组成。

注意这里1×1卷积是为了对特征通道数量进行降维;而池化才是为了降低特征图的尺寸。

在DenseNet模型中,Dense Block的每个子结构都将前面所有子结构的输出结果作为输入。例如,假设我们考虑Dense Block(3),该Block包含32个3×3的卷积操作。如果每一层输出的特征通道数为32,那么第32层的3×3卷积操作的输入通道数将是前31层所有输出的累积,即31×32,加上上一个Dense Block的输出特征通道数。这可以使得特征通道数达到近1000。

为了降低特征通道数,DenseNet在每个Dense Block后引入了Transition Layer,其中使用了1×1的卷积核进行降维操作。Transition Layer有一个参数名为’reduction’,取值范围为0到1,用于控制输出通道数相对于输入通道数的比例。默认情况下,'reduction’设为0.5,这意味着Transition Layer将特征通道数减少到原来的一半,然后将结果传给下一个Dense Block。

此外,为了防止过拟合,模型在最后的神经网络层中引入了dropout操作,用于随机丢弃一部分神经元,降低模型复杂度。其代码实现如下所示。

1.class _Transition(nn.Sequential):    
2.    def __init__(self,    
3.                 input_c: int,    
4.                 output_c: int):    
5.        super(_Transition, self).__init__()    
6.        self.add_module("norm", nn.BatchNorm2d(input_c))    
7.        self.add_module("relu", nn.ReLU(inplace=True))    
8.        self.add_module("conv", nn.Conv2d(input_c, output_c, kernel_size=1, stride=1, bias=False))    
9.        self.add_module("pool", nn.AvgPool2d(kernel_size=2, stride=2)) 

2.3 Growth rate

在Denseblock中,假设每一个卷积操作的输出为K个feature map, 那么第i层网络的输入便为(i-1)×K +(上一个Dense Block的输出channel), 这个K在论文中的名字叫做Growth rate, 默认是等于32的,这里我们可以看到DenseNet和现有网络的一个主要的不同点:DenseNet可以接受较少的特征图数量(32)作为网络层的输出。具体从网络参数如下图所示:

DenseNet Configuration

值得注意的是这里每个dense block的3X3卷积前面都包含了一个1X1的卷积操作,就是所谓的bottleneck layer,目的是减少输入的feature map数量,既能对通道数量降维来减少计算量,又能融合各个通道的特征。

三、Model Comparation

DenseNet and ResNet

上图是DenseNet与ResNet的对比图,在相同的错误率下,DenseNet的参数更少,计算复杂度也越低。
但是,DenseNet在实际训练中是非常占用内存的!原因是在计算的过程中需要保留浅层的feature map为了与后面的feature map就行拼接。简单说,虽然DenseNet参数量少,但是训练过程中的中间产物(feature map)多;这可能也是为什么DenseNet没有ResNet流行的原因吧。

四、 FractalNet

FractalNet(分型网络),2016年Gustav Larsson首次提出,忽然想起这个网络跟DenseNet有些类似,因此这里做简单的介绍。

  • 分形网络不像resNet那样连一条捷径,而是通过不同长度的子路径组合,网络选择合适的子路径集合提升模型表现

  • 分形网络体现的一种特性为:浅层子网提供更迅速的回答,深层子网提供更准确的回答。

FractalNet block
  1. 图中以粉红色的卷积层Convolution为基础层,实际上可以为其它类型的层或者子网络;
  2. 绿色的Join层一般可以用相加或concat,这里采取了相加然后取平均,因此所有基础层可以使用一样的channel数量
  3. fC(z) 中C表示列数,z表示输入,C=1表示一个基础层
  4. fC+1(z) 则如图所示,在右边叠加两个fC(z) ,左边接一个基础层
  5. 以此类推,当C等于4的时候,可以得到图中的f4(z)
  6. f4(z)作为一个block中,如图中最右边的网络所示,完整的网络接了5个block,block之间用Pool层连接,最后是预测层
  7. 令block个数为B,每个block中的列数为C,网络的总深度为B⋅2C−1

观察上图中左半部分,描述的是网络的单个模块(block)的结构。注意,这里的fC不是CNN中常用到的全连接层, 而是指分形次数为 C 的模块。fC 模块的表达式如下:
f 1 ( z ) = conv ⁡ ( z ) f C + 1 = [ ( f C ∘ f C ) ( z ) ] ⊕ [ conv ⁡ ( z ) ] \begin{aligned} &f_1(z)=\operatorname{conv}(z) \\ &f_{C+1}=\left[\left(f_C \circ f_C\right)(z)\right] \oplus[\operatorname{conv}(z)] \end{aligned} f1(z)=conv(z)fC+1=[(fCfC)(z)][conv(z)]

其中, ⊕ \oplus 是一个聚合(join)操作,本文推荐使用均值,而非常见的concat 或 addition。

中间图就是一个典型的 C=4 的block。把这些block堆叠起来,加上pooling和prediction层,就是完整的分类网络了,也就是右图。

网络结构看完了,FratalNet并不存在像ResNet那样skip connect的结构。但是,实际上如果把fC模块改成:
f C + 1 = [ ( f C ∘ f C ) ( z ) ] ⊕ z f_{C+1}=\left[\left(f_C \circ f_C\right)(z)\right] \oplus z fC+1=[(fCfC)(z)]z

这就变成一个 DenseNet 的结构了。。。
而众所皆知,DenseNet某种意义上是ResNet的改进版。。。所以个人感觉其实FratalNet也算是ResNet的近亲,这大概也是为什么业界还是ResNet用得多的原因吧——反正都差不多,不如挑个结构最简单的。为了方便大家理解,这里以Fractal的画图方式画一下Dense Block(包含4层)。

FractalNet block

最后,路径舍弃(Drop path)也是FractalNet的贡献之一,可以看作一种新的正则化规则。
ResNet收敛快,但还是要经过基本的卷积结构,卷积通用的问题是缺少有效的正则化方法。对于深的如ResNet采用BN,对于宽的如WideResNet采用Dropout。而FractalNet是基于路径,舍弃一些路径,是一种新的正则化方法(与Stochastic Depth Net中的随机失活几乎一样,好奇的可以翻我之前的博文。

对路径舍弃采用了 50% 局部以及 50% 全局的混合采样:
局部:连接层以固定几率舍弃每个输入,但我们保证至少一个输入保留。如图第1、3个。
全局:为了整个网络选出每条路径,并限制其为单列结构,激励每列成为有力的预测器,每列只做卷积。如图第2、4个。

FractalNet block

实验训练的时候,mini-batch之间交叉使用Local和Global。

五、Model Code

这里给出模型搭建的python代码(基于pytorch实现)。完整的代码是基于图像分类问题的(包括训练和推理脚本,自定义层等)详见我的GitHub: 完整代码链接

import re
from typing import Any, List, Tuple
from collections import OrderedDict

import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.utils.checkpoint as cp
from torch import Tensor


class _DenseLayer(nn.Module):
    def __init__(self, input_c: int, growth_rate: int, bn_size: int, drop_rate: float, memory_efficient: bool = False):
        super(_DenseLayer, self).__init__()

        self.add_module("norm1", nn.BatchNorm2d(input_c))
        self.add_module("relu1", nn.ReLU(inplace=True))
        self.add_module("conv1", nn.Conv2d(in_channels=input_c,  out_channels=bn_size * growth_rate, kernel_size=1, stride=1, bias=False))
        self.add_module("norm2", nn.BatchNorm2d(bn_size * growth_rate))
        self.add_module("relu2", nn.ReLU(inplace=True))
        self.add_module("conv2", nn.Conv2d(bn_size * growth_rate, growth_rate, kernel_size=3, stride=1, padding=1, bias=False))
        self.drop_rate = drop_rate
        self.memory_efficient = memory_efficient

    def bn_function(self, inputs: List[Tensor]):
        concat_features = torch.cat(inputs, 1)
        bottleneck_output = self.conv1(self.relu1(self.norm1(concat_features)))
        return bottleneck_output

    @staticmethod
    def any_requires_grad(inputs: List[Tensor]):
        for tensor in inputs:
            if tensor.requires_grad:
                return True
        return False

    @torch.jit.unused
    def call_checkpoint_bottleneck(self, inputs: List[Tensor]):
        def closure(*inp):
            return self.bn_function(inp)

        return cp.checkpoint(closure, *inputs)

    def forward(self, inputs: Tensor):
        if isinstance(inputs, Tensor):
            prev_features = [inputs]
        else:
            prev_features = inputs

        if self.memory_efficient and self.any_requires_grad(prev_features):
            if torch.jit.is_scripting():
                raise Exception("memory efficient not supported in JIT")

            bottleneck_output = self.call_checkpoint_bottleneck(prev_features)
        else:
            bottleneck_output = self.bn_function(prev_features)

        new_features = self.conv2(self.relu2(self.norm2(bottleneck_output)))
        if self.drop_rate > 0:
            new_features = F.dropout(new_features,  p=self.drop_rate, training=self.training)

        return new_features


class _DenseBlock(nn.ModuleDict): 
    def __init__(self, num_layers: int, input_c: int, bn_size: int, growth_rate: int,  drop_rate: float,  memory_efficient: bool = False):
        super(_DenseBlock, self).__init__()
        for i in range(num_layers):
            layer = _DenseLayer(input_c + i * growth_rate, growth_rate=growth_rate, bn_size=bn_size, drop_rate=drop_rate,  memory_efficient=memory_efficient)
            self.add_module("denselayer%d" % (i + 1), layer)

    def forward(self, init_features: Tensor):
        features = [init_features]
        for name, layer in self.items():
            new_features = layer(features)
            features.append(new_features)
        return torch.cat(features, 1)


class _Transition(nn.Sequential):
    def __init__(self,
                 input_c: int,
                 output_c: int):
        super(_Transition, self).__init__()
        self.add_module("norm", nn.BatchNorm2d(input_c))
        self.add_module("relu", nn.ReLU(inplace=True))
        self.add_module("conv", nn.Conv2d(input_c, output_c, kernel_size=1, stride=1, bias=False))
        self.add_module("pool", nn.AvgPool2d(kernel_size=2, stride=2))


class DenseNet(nn.Module):
    """
    Densenet-BC model class for imagenet
    Args:
        growth_rate (int) - how many filters to add each layer (`k` in paper)
        block_config (list of 4 ints) - how many layers in each pooling block
        num_init_features (int) - the number of filters to learn in the first convolution layer
        bn_size (int) - multiplicative factor for number of bottle neck layers
          (i.e. bn_size * k features in the bottleneck layer)
        drop_rate (float) - dropout rate after each dense layer
        num_classes (int) - number of classification classes
        memory_efficient (bool) - If True, uses checkpointing. Much more memory efficient
    """

    def __init__(self,
                 growth_rate: int = 32,
                 block_config: Tuple[int, int, int, int] = (6, 12, 24, 16),
                 num_init_features: int = 64,
                 bn_size: int = 4,
                 drop_rate: float = 0,
                 num_classes: int = 1000,
                 memory_efficient: bool = False):
        super(DenseNet, self).__init__()

        # first conv+bn+relu+pool
        self.features = nn.Sequential(OrderedDict([
            ("conv0", nn.Conv2d(3, num_init_features, kernel_size=7, stride=2, padding=3, bias=False)),
            ("norm0", nn.BatchNorm2d(num_init_features)),
            ("relu0", nn.ReLU(inplace=True)),
            ("pool0", nn.MaxPool2d(kernel_size=3, stride=2, padding=1)),
        ]))

        # each dense block
        num_features = num_init_features
        for i, num_layers in enumerate(block_config):
            block = _DenseBlock(num_layers=num_layers,
                                input_c=num_features,
                                bn_size=bn_size,
                                growth_rate=growth_rate,
                                drop_rate=drop_rate,
                                memory_efficient=memory_efficient)
            self.features.add_module("denseblock%d" % (i + 1), block)
            num_features = num_features + num_layers * growth_rate

            if i != len(block_config) - 1:
                trans = _Transition(input_c=num_features,
                                    output_c=num_features // 2)
                self.features.add_module("transition%d" % (i + 1), trans)
                num_features = num_features // 2

        # finnal batch norm
        self.features.add_module("norm5", nn.BatchNorm2d(num_features))

        # fc layer
        self.classifier = nn.Linear(num_features, num_classes)

        # init weights
        for m in self.modules():
            if isinstance(m, nn.Conv2d):
                nn.init.kaiming_normal_(m.weight)
            elif isinstance(m, nn.BatchNorm2d):
                nn.init.constant_(m.weight, 1)
                nn.init.constant_(m.bias, 0)
            elif isinstance(m, nn.Linear):
                nn.init.constant_(m.bias, 0)

    def forward(self, x: Tensor):
        features = self.features(x)
        out = F.relu(features, inplace=True)
        out = F.adaptive_avg_pool2d(out, (1, 1))
        out = torch.flatten(out, 1)
        out = self.classifier(out)
        return out


def densenet121(num_classes):
    # Top-1 error: 25.35%
    # 'densenet121': 'https://download.pytorch.org/models/densenet121-a639ec97.pth'
    return DenseNet(growth_rate=32,
                    block_config=(6, 12, 24, 16),
                    num_init_features=64,
                    num_classes=num_classes)


def densenet169(num_classes):
    # Top-1 error: 24.00%
    # 'densenet169': 'https://download.pytorch.org/models/densenet169-b2777c0a.pth'
    return DenseNet(growth_rate=32,
                    block_config=(6, 12, 32, 32),
                    num_init_features=64,
                    num_classes=num_classes)


def densenet201(num_classes):
    # Top-1 error: 22.80%
    # 'densenet201': 'https://download.pytorch.org/models/densenet201-c1103571.pth'
    return DenseNet(growth_rate=32,
                    block_config=(6, 12, 48, 32),
                    num_init_features=64,
                    num_classes=num_classes)


def densenet161(num_classes):
    # Top-1 error: 22.35%
    # 'densenet161': 'https://download.pytorch.org/models/densenet161-8d451a50.pth'
    return DenseNet(growth_rate=48,
                    block_config=(6, 12, 36, 24),
                    num_init_features=96,
                    num_classes=num_classes)

总结

DenseNet是一种深度学习模型,它在计算机视觉任务中表现出色。与传统的卷积神经网络相比,DenseNet引入了密集连接的概念,使得网络内部的信息流动更加充分和高效。以下是对DenseNet模型的总结:

密集连接:DenseNet的核心思想是通过密集连接来增强特征传播和重用。在传统的卷积神经网络中,每个层只连接到其后续的层。而在DenseNet中,每个层都与后续所有层直接相连。这种密集连接使得底层的特征可以直接传递到更深层,有效地解决了梯度消失的问题。

特征重用:由于密集连接,DenseNet中的每个层都可以接收到前面所有层的特征图。这种设计使得网络能够更好地重用之前层的特征信息,从而提高了参数的利用效率和整体模型的性能。

层间特征融合:DenseNet采用了一种称为“密集块”的结构,它由多个具有相同输出尺寸的层组成。这些层通过拼接操作将其输入和输出连接在一起。这样,每个密集块都可以将前面所有层的特征融合在一起,形成更丰富的表示。

参数和计算效率:尽管DenseNet中参数的数量比传统的卷积神经网络稍多,但由于特征的重用,它在实际训练和推断中可以更好地利用参数。此外,DenseNet中的特征重用还减少了冗余计算,使得整体计算效率更高。

模型性能:DenseNet在许多计算机视觉任务上表现出色,包括图像分类、目标检测和图像分割等。由于密集连接的引入,DenseNet能够捕捉更丰富的特征表示,提高模型的准确性和鲁棒性。

总之,DenseNet通过引入密集连接的概念,实现了更充分和高效的信息流动,增强了特征重用和表示能力。这使得DenseNet成为了一个强大的深度学习模型,在计算机视觉领域得到广泛应用并取得了显著的性能提升。

  • 0
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
岩石图像分类识别是一个典型的图像分类问题,可以使用卷积神经网络(CNN)来解决。下面是一个基于CNN的岩石图像分类识别的Python示例代码。 首先,我们需要导入必要的库: ```python import numpy as np import pandas as pd import matplotlib.pyplot as plt import keras from keras.models import Sequential from keras.layers import Dense, Dropout, Flatten, Conv2D, MaxPooling2D from keras.utils import np_utils from keras.datasets import rockpaperscissors ``` 接下来,我们需要加载数据集并对其进行预处理: ```python (X_train, y_train), (X_test, y_test) = rockpaperscissors.load_data() # 将像素值缩放到0到1之间 X_train = X_train.astype('float32') / 255 X_test = X_test.astype('float32') / 255 # 对标签进行编码 y_train = np_utils.to_categorical(y_train, 3) y_test = np_utils.to_categorical(y_test, 3) # 调整图像大小 X_train = np.array([keras.preprocessing.image.img_to_array(keras.preprocessing.image.load_img(img, target_size=(150, 150))) for img in X_train]) X_test = np.array([keras.preprocessing.image.img_to_array(keras.preprocessing.image.load_img(img, target_size=(150, 150))) for img in X_test]) # 确认输入形状 input_shape = X_train[0].shape ``` 接下来,我们可以定义CNN模型: ```python model = Sequential() # 添加卷积层和最大池化层 model.add(Conv2D(32, kernel_size=(3, 3), activation='relu', input_shape=input_shape)) model.add(MaxPooling2D(pool_size=(2, 2))) model.add(Conv2D(64, kernel_size=(3, 3), activation='relu')) model.add(MaxPooling2D(pool_size=(2, 2))) model.add(Conv2D(128, kernel_size=(3, 3), activation='relu')) model.add(MaxPooling2D(pool_size=(2, 2))) # 添加全连接层和输出层 model.add(Flatten()) model.add(Dense(128, activation='relu')) model.add(Dropout(0.5)) model.add(Dense(3, activation='softmax')) # 编译模型 model.compile(loss='categorical_crossentropy', optimizer='adam', metrics=['accuracy']) ``` 接下来,我们可以训练模型: ```python history = model.fit(X_train, y_train, batch_size=32, epochs=10, verbose=1, validation_data=(X_test, y_test)) ``` 最后,我们可以评估模型性能并进行预测: ```python score = model.evaluate(X_test, y_test, verbose=0) print('Test loss:', score[0]) print('Test accuracy:', score[1]) # 进行预测 predictions = model.predict(X_test) ``` 这是一个基于CNN的岩石图像分类识别的Python示例代码。使用这个代码,你可以构建一个能够对岩石图像进行分类的模型。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值