【机器学习】详解 ResNeSt


目录

摘要

一、介绍 

二、相关工作

2.1 现代 CNN 架构

2.2 多路和特征图注意力

2.3 神经架构搜索

三、原理 (Split-Attention Networks)

3.1 Feature-Map Group

3.2 Split Attention in Cardinal Groups

3.3 ResNeSt Block

3.4 Instantiation, Acceleration, and Computational Costs

3.5 Relation to Existing Attention Methods

四、网络和训练

4.1 Large Mini-batch Distributed Training

4.2 Label Smooth

4.3 Auto Augmentation

4.4 Mixup Training

4.5 Large Crop Size  

4.6 Regularization

五、结论

六、代码

6.1 Split Attention

6.2 ResNest BottleNeck

6.3 ResNeSt 



摘要

        虽然图像分类模型最近不断发展,但由于其简单和模块化的结构,大多数下游应用程序 (如目标检测和语义分割) 仍使用 ResNet 变体作为主干网络。 我们提出了一个模块化的 Split-Attention Block,可以跨特征图组执行注意力。通过堆叠 ResNet 风格的 Split-Attention Block,我们获得了一个新的 ResNet 变体 —— ResNeSt。我们的网络保留了整个 ResNet 结构以直接用于下游任务,而不会引入额外的计算成本。


一、介绍 

        图像分类是计算机视觉研究中的一项基本任务。为图像分类训练的网络通常作为为其他应用设计的神经网络的支柱,例如目标检测、语义分割和姿态估计。最近的工作通过大规模神经架构搜索 (NAS) 显着提高了图像分类精度。尽管它们具有 SOTA 性能,但这些源自 NAS 的模型通常并未针对通用/商业处理硬件 (CPU/GPU) 上的训练效率或内存使用进行优化。由于内存消耗过多,这些模型的一些较大版本甚至无法在具有适当的 batch size 的 GPU 上训练。这限制了 NAS 衍生模型在其他应用中的采用,尤其是涉及密集预测的任务,例如分割。

        下游应用的最新工作仍然使用 ResNet 或其变体作为主干 CNN,其简单的模块化设计可轻松适应各种任务。然而,由于 ResNet 模型最初是为图像分类而设计的,由于有限的感受野和跨通道交互的缺乏,它们可能不适合各种下游应用。 这意味着提高给定计算机视觉任务的性能需要 “网络手术” 来修改 ResNet,使其对特定任务更有效。例如,一些方法添加金字塔模块、引入长程连接 或 使用跨通道特征图注意力。 虽然这些方法确实提高了某些任务的迁移学习性能,但它们提出了一个问题:能否创建一个具有普遍改善特征表示的多功能主干,从而同时提高多个任务的性能跨通道信息已在下游应用中取得成功,而最近的图像分类网络更多地关注按组或深度卷积。尽管它们在分类任务中具有出色的计算量和准确率权衡,但这些模型不能很好地迁移到其他任务,因为它们的孤立表示无法捕获跨通道关系。 因此,需要具有跨通道表示的网络。

        作为本文的第一个贡献,我们探索了 ResNet 的简单架构修改,在单个网络块中结合了 feature split attention。更具体地,我们的每个块 (沿通道维度) 将特征图划分为几个组 和 细粒度的子组 或 splits,其中每个组的特征表示 是通过其 splits 的表示的加权组合来确定的 (使用基于全局上下文信息选择的权重)。我们将生成的单元称为 Split-Attention Block,它保持简单和模块化。通过堆叠多个 Split-Attention Block,我们创建了一个类似于 ResNet 的网络,称为 ResNeSt (S 代表 “split”)。 我们的架构无需比现有 ResNet 变体更多的计算,并且很容易被用作其他视觉任务的主干。

        本文的第二个贡献是图像分类和迁移学习应用的大规模基准测试。我们发现使用 ResNeSt 主干的模型能够在多个任务上达到 SOTA 性能,即:图像分类、目标检测、实例分割 和 语义分割。所提出的 ResNeSt 优于所有现有的 ResNet 变体,并且具有相同的计算效率,甚至比通过 NAS 生成的 SOTA CNN 模型实现了更好的速度-准确度权衡,如表 1 所示。使用 ResNeSt-101 主干的 Cascade-RCNN 模型在 MS-COCO 实例分割上实现了 48.3% 的 box mAP 和 41.56% 的 mask mAP。我们的单个 DeepLabV3 模型再次使用 ResNeSt-101 主干,在 ADE20K 场景解析验证集上实现了 46.9% 的 mIoU,比之前的 SOTA 结果高出 1% 以上的 mIoU。其他结果可在第 5 节和第 6 节中找到。


二、相关工作

2.1 现代 CNN 架构

        自 AlexNet 以来,深度 CNN 主导了图像分类。随着这一趋势,研究已经从工程手工特征转向工程网络架构。NIN 首先使用全局平均池化层代替重全连接层,并采用 1×1 卷积层学习特征图通道的非线性组合,这是第一种特征图注意力机制。VGG-Net 提出了一种模块化网络设计策略,重复堆叠相同类型的网络块,简化了下游应用的网络设计和迁移学习的工作流程。Highway Network 引入了 highway 连接,使得信息可以跨多层无衰减地流动,并有助于网络收敛。在开创性工作的成功基础上,ResNet 引入了 identity 跳跃连接,减轻了深度神经网络中梯度消失的困难,并允许网络学习更深层次的特征表示。ResNet 已成为最成功的 CNN 架构之一,已被各种计算机视觉应用采用。


2.2 多路和特征图注意力

        多路表示 GoogleNet 中取得了成功,其每个网络块由不同的卷积核组成。ResNeXt 在 ResNet Bottle Block 中采用分组卷积,将多路结构转换为统一操作。SE-Net 通过自适应地重新校准通道特征响应来引入通道注意机制。SK-Net 将特征图注意力带到两个网络分支上。受先前方法的启发,我们的网络将通道注意力推广到特征图组表示中,可以使用统一的 CNN 算子进行模块化和加速。


2.3 神经架构搜索

        随着计算能力的增加,人们的兴趣已经开始从手动设计的架构转移到针对特定任务自适应定制的系统搜索架构。最近的神经架构搜索算法自适应地生成了实现最先进分类性能的 CNN 架构,例如:Amoe baNetMNASNet EfficientNet。 尽管它们在图像分类方面取得了巨大成功,但元网络结构彼此不同,这使得下游模型难以建立。 相反,我们的模型保留了 ResNet 元结构,可以直接应用于许多现有的下游模型。 我们的方法还可以增加神经架构搜索的搜索空间,并有可能提高整体性能,这可以在未来的工作中进行研究。


三、原理 (Split-Attention Networks)

        我们的 Split-Attention Block 是一个计算单元,由 Feature-Map GroupSplit-Attention 操作组成。图 1 (右) 展示了 Split-Attention Block。 


3.1 Feature-Map Group

        在 ResNeXt 块中,特征可按通道维度 C 分为若干组,特征图组的数量 由 基数 (cardinality) 超参数 K 给出。我们将得到的 特征图组 称为 基数组 (cardinal groups) (一次划分)。我们引入了一个新的 基数 (radix) 超参数 R,它表示 基数组 (cardinal groups) 内进一步划分的 splits 数 (二次划分)。因此,特征组的总数 G = KR。可以对每个单独的 split 应用一系列变换 \{ F_1, F_2, ...F_G \},那么每个 split 的中间表示便是 U_i = F_i(X),因为 i \in \{1, 2, ...G\}


3.2 Split Attention in Cardinal Groups

        可通过融合 将 若干基数组构成一个组合表示,即 跨多个 splits 按元素求和。第 k 个基数组表示是 \hat{U}^k = \sum^{Rk}_{j=R(k-1)+1} U_j (第 k 组的 R 个 splits U_j, \; j \in \{ R(k-1)+1, R(k-1)+2, ..., Rk \} 之和),其中 \hat{U}^k \in \mathbb{R}^{H \times W \times C / K}, \; k \in 1, 2, ..., K 且 H, W, C 为块输出特征图的形状。可通过跨空间维度 s^k \in \mathbb{R}^{C / K} 的 全局平均池化 来集合具有嵌入通道统计数据的全局上下文信息。此处第 k 个基数组的第 c 个分量 (split) 计算如下:

        基数组表示 V^k \in \mathbb{R}^{H \times W \times C / K} 的加权融合 是 通过 按通道软注意力 聚合的,其中每个特征图通道是使用 splits 上的加权组合产生的。第 c 个通道计算如下:

         其中 \alpha^k_i(c) 表示 (软) 分配权重:

         映射 g^c_i 根据全局上下文表示 s^k 确定第 c 个通道的每个 splits 的权重。


3.3 ResNeSt Block

        然后将基数组表示沿通道维度拼接起来:V = Concat \{V^1 , V^2 , ...V^K \}。与标准残差块一样,若输入和输出特征图形状相同,则 Split-Attention Block 的最终输出 Y 由残差连接生成:Y = V + X。对于具有跨步 (stride) 的块,将适当的变换 T 应用于残差连接以对齐输出形状:Y = V + T (X)。例如,T 可以是跨步卷积或卷积和池化的组合。


3.4 Instantiation, Acceleration, and Computational Costs

        图 1 (右) 展示了实例化的 Split-Attention Block,其中组转换 F_i 是 1×1 卷积后接 3×3 卷积 (灰色色块),并且注意力权重函数 G 使用两个 ReLU 激活的全连接层进行参数化。我们在基数主视图中绘制此图 (具有相同基数索引的特征图组彼此相邻),以便于描述整体逻辑。 通过将布局切换到基数主视图,可以使用标准 CNN 层 (例如组卷积、组全连接层和 softmax 操作) 轻松加速该块,我们将在补充材料中详细描述。Split-Attention 块的参数数量和 FLOPS 与具有相同基数和通道数的残差块大致相同。


3.5 Relation to Existing Attention Methods

        Squeeze-and-attention 的想法在 SE-Net 中被首次引入 (原文称为 excitation),其利用全局上下文来预测按通道顺序的注意力因子。当基数为 1 时,Split-Attention Block 对每个基数组应用 squeeze-and-attention 操作,而 SE-Net 在整个 Block 上运行而不论有多少组。像 SK-Net 这样的先前的模型在两个网络分支间引入了特征注意,但它们的操作并未针对训练效率和扩展到大型神经网络进行优化。 我们的方法在基数组设置中概括了先前关于特征图注意力的工作,并且其实现仍具有计算效率。图 1 展示了与 SE-Net 和 SK-Net 块的总体比较。


四、网络和训练

        我们现在描述实验中使用的网络设计和训练策略。 首先,我们详细介绍了一些进一步提高性能的调整,其中一些已经在 《Bag of tricks to train convolutional neural networks for image classifification》中得到了经验验证。

4.1 Large Mini-batch Distributed Training

        根据先前的工作,我们用 8 个服务器 (共 64 个 GPUs) 并行训练模型。

        学习率根据余弦策略调整。我们遵循基于小 batch size 大小线性扩展初始学习率的常见做法。初始学习率由 \eta = \frac{B}{256} \eta_{base} 给出,其中 B 是小 batch size 大小,我们使用 \eta_{base} = 0.1 作为基础学习率。这种 warm-up 策略应用于前 5 个 epochs,逐渐将学习率从 0 线性增加到余弦策略的初始值。

        按照大 batch size 训练的建议,BN 的放缩参数 (Scale Parameter) \gamma 在每个块最后的 BN 操作中初始化为 0。


4.2 Label Smooth

        标签平滑最初被用于改进 Inception-V2 的训练。回想一下,我们网络的预测类别概率 q 引起的交叉熵损失是根据真实值 p 计算的:

        其中 K 是类别总数,p_i 是第 i 个类别的真实概率,q_i 是网络对第 i 个类别的预测概率。与标准图像分类一样,我们定义: probs q_i = \frac{exp(z_i)}{\sum^K_{j=1}exp(z_i)},其中 z_i 是网络输出层产生的 logits。当提供的标签是类别而非类别概率 (硬标签) 时,如果 i 等于真实类别 c,则 p_i = 1,否则 p_i = 0。因此在此设置中:l_{hard} (p, q) = -logq_c = -z_c + log(\sum^K_{j=1} exp(z_i))。在训练的最后阶段,对于 j = c,logits z_j 往往非常小,而 z_c 被推到其最佳值 \infty,这会导致过拟合。标签平滑不将硬标签指定为目标,而用平滑的真实概率。

        小常数 \varepsilon > 0。这减轻了网络过度自信和过拟合。


4.3 Auto Augmentation

        Auto-Augment 是一种使用变换后的图像来增强训练数据的策略,其中变换是自适应地学习的 (而非人工/手动设置的)。本文引入了 16 种不同类型的图像抖动 (jittering) 变换,其中一种基于移位、旋转和颜色抖动等两个连续变换的 24 种不同组合来增强数据。每个变换的程度可用一个相对参数来控制 (例如 旋转角度),并且变换有概率被略过 (p \neq 1.0)。尝试各种候选增强策略的搜索会返回最佳的 24 个最佳组合。然后在训练期间随机选择这 24 个策略之一并应用于每个样本图像。最初的 Auto-Augment 实现使用强化学习来搜索这些超参数,将它们视为离散搜索空间中的分类值。对于连续搜索空间,它在搜索最佳值之前首先离散化可能的值。


4.4 Mixup Training

        Mixup 是另一种数据增强策略,它 从训练数据中生成随机图像对的加权组合。给定两个图像及其真实标签:(x^{(i)} , y^{(i)}), (x^{(j)} , y^{(j)}),合成训练示例 (\hat{x}, \hat{y}) 生成自:

         其中,\lambda \sim Beta(\alpha = 0.2) 独立采样自各经增强的样本。


4.5 Large Crop Size  

        图像分类研究通常比较在共享相同裁剪大小的图像上运行的不同网络的性能。ResNet 变体通常使用固定的训练裁剪大小 224,而 Inception-Net 系列使用的训练裁剪大小为 299。最近,EffifficientNet 方法已经证明 为更深、更宽的网络增加输入图像大小可能会更好地权衡精度与 FLOPS。为公平比较,我们在比较 ResNeSt 与 ResNet 变体时使用 224 的裁剪大小,与其他方法比较时使用 256 的裁剪大小。


4.6 Regularization

        即使对于大型数据集,非常深的神经网络也往往会过拟合。为防止这种情况,dropout 正则化在训练期间 (但不是在推理期间) 随机屏蔽一些神经元,以形成隐式网络集成。在最终的全连接层前,将失活概率为 0.2 的 dropout 层应用于超过 200 层的网络。

        我们还在网络的最后两个阶段将 DropBlock 层应用于卷积层。作为 dropout 的一种结构化变体,DropBlock 随机屏蔽了局部块区域,在特定正则化卷积层方面比 dropout 更有效。

        最后,我们还应用了 权重衰减 (L2 正则化),这也有助于 稳定训练。先前关于大型小 batch size 训练的工作表明,权重衰减应仅应用于卷积层和全连接层的权重。我们不使任何其他网络参数受到权重衰减,包括 BN 层中的偏差单元、\gamma\beta


五、结论

        这项工作提出了具有新颖 Split-Attention Block 的 ResNeSt 架构,该架构普遍改进了学习的特征表示,以提高图像分类、目标检测、实例分割和语义分割的性能。在后面的下游任务中,通过简单地将主干网络切换到我们的 ResNeSt 所产生的经验改进明显优于应用于标准主干 (如 ResNet) 的任务特定修改。我们的 Split-Attention Block 易于使用且计算效率高,因此应该广泛适用于视觉任务。


六、代码

6.1 Split Attention

# https://github.com/huggingface/pytorch-image-models/blob/main/timm/layers/split_attn.py

""" Split Attention Conv2d (for ResNeSt Models)
Paper: `ResNeSt: Split-Attention Networks` - /https://arxiv.org/abs/2004.08955
Adapted from original PyTorch impl at https://github.com/zhanghang1989/ResNeSt
Modified for torchscript compat, performance, and consistency with timm by Ross Wightman
"""
import torch
import torch.nn.functional as F
from torch import nn

from .helpers import make_divisible


class RadixSoftmax(nn.Module):
    def __init__(self, radix, cardinality):
        super(RadixSoftmax, self).__init__()
        self.radix = radix  # 每个基数组 (cardinality) 下划分的 splits 数 R
        self.cardinality = cardinality  # 基数组数 K

    def forward(self, x):
        # x.shape = (B, group_width) = (B, c)
        batch = x.size(0)
        if self.radix > 1:  
            # (B, c) -> (B, K, R, c/(KR)) -> (B, R, K, c/(KR))
            x = x.view(batch, self.cardinality, self.radix, -1).transpose(1, 2)
            # 沿 splits 维度 R 计算 Softmax 用于为各 splits 加权
            x = F.softmax(x, dim=1)
            x = x.reshape(batch, -1)
        else:
            x = torch.sigmoid(x)
        return x


class SplitAttn(nn.Module):
    """Split-Attention (aka Splat)
    """
    def __init__(self, in_channels, out_channels=None, kernel_size=3, stride=1, padding=None,
                 dilation=1, groups=1, bias=False, radix=2, rd_ratio=0.25, rd_channels=None, rd_divisor=8,
                 act_layer=nn.ReLU, norm_layer=None, drop_block=None, **kwargs):
        super(SplitAttn, self).__init__()

        out_channels = out_channels or in_channels  # group_width
        self.radix = radix  # 每个基数组 (cardinality) 下划分的 splits 数 R
        self.drop_block = drop_block

        # 因为 in_channels = out_channels = group_width
        # 所以 mid_chs = group_width * R 
        # 表示每个 cardinal group 有 R 个 splits, 共有 mid_chs 个通道
        mid_chs = out_channels * radix  

        if rd_channels is None:
            attn_chs = make_divisible(in_channels * radix * rd_ratio, min_value=32, divisor=rd_divisor)
        else:
            attn_chs = rd_channels * radix

        padding = kernel_size // 2 if padding is None else padding
        self.conv = nn.Conv2d(
            in_channels, mid_chs, kernel_size, stride, padding, dilation,
            groups=groups * radix, bias=bias, **kwargs)

        self.bn0 = norm_layer(mid_chs) if norm_layer else nn.Identity()
        self.act0 = act_layer(inplace=True)

        self.fc1 = nn.Conv2d(out_channels, attn_chs, 1, groups=groups)
        self.bn1 = norm_layer(attn_chs) if norm_layer else nn.Identity()
        self.act1 = act_layer(inplace=True)

        self.fc2 = nn.Conv2d(attn_chs, mid_chs, 1, groups=groups)  # 通道数 c' -> c

        self.rsoftmax = RadixSoftmax(radix, groups)

    def forward(self, x):
        # 特征图 x 包含了各 splits
        x = self.conv(x)
        x = self.bn0(x)
        if self.drop_block is not None:
            x = self.drop_block(x)
        x = self.act0(x)

        B, RC, H, W = x.shape  # RC = mid_chs = group_width (c) * self.radix (R) 
        if self.radix > 1:
            # x.shape = (B, self.radix, group_width, H, W) 可见 x 是 R 个 splits 的集合
            x = x.reshape((B, self.radix, RC // self.radix, H, W))
            # 沿 radix 维对各 splits 按元素求和
            x_gap = x.sum(dim=1)  # x_gap.shape = (B, group_width, H, W)
        else:
            x_gap = x

        # 对 H, W 维取均值 (相当于全局平均池化)
        x_gap = x_gap.mean((2, 3), keepdim=True)  # x_gap.shape = (B, group_width) = (B, c)
        # Dense c' + BN + ReLU
        x_gap = self.fc1(x_gap)  # x_gap.shape = (B, attn_chs) = (B, c')
        x_gap = self.bn1(x_gap)
        x_gap = self.act1(x_gap)

        # Dense c
        x_attn = self.fc2(x_gap)  # x_gap.shape = (B, group_width) = (B, c)

        # r-Softmax
        x_attn = self.rsoftmax(x_attn).view(B, -1, 1, 1)

        # 按元素相乘计算各 splits 的注意力
        if self.radix > 1:
            # 此时 x.shape = (B, self.radix, RC // self.radix, H, W)
            out = (x * x_attn.reshape((B, self.radix, RC // self.radix, 1, 1))).sum(dim=1)
        else:
            out = x * x_attn

        return out.contiguous()

6.2 ResNest BottleNeck

# https://github.com/huggingface/pytorch-image-models/blob/4d9c3ae2fb7cc4739ec57d4c06254d2ffc7e2c89/timm/models/resnest.py

class ResNestBottleneck(nn.Module):
    """ResNet Bottleneck
    """
    # pylint: disable=unused-argument
    expansion = 4

    def __init__(self, inplanes, planes, stride=1, downsample=None,
                 radix=1, cardinality=1, base_width=64, avd=False, avd_first=False, is_first=False,
                 reduce_first=1, dilation=1, first_dilation=None, act_layer=nn.ReLU, norm_layer=nn.BatchNorm2d,
                 attn_layer=None, aa_layer=None, drop_block=None, drop_path=None):
        super(ResNestBottleneck, self).__init__()

        assert reduce_first == 1   # not supported
        assert attn_layer is None  # not supported
        assert aa_layer is None    # TODO not yet supported
        assert drop_path is None   # TODO not yet supported

        # group_width = planes * cardinality (K)
        group_width = int(planes * (base_width / 64.)) * cardinality
        first_dilation = first_dilation or dilation
        if avd and (stride > 1 or is_first):
            avd_stride = stride
            stride = 1
        else:
            avd_stride = 0
        self.radix = radix  # 每个基数组 (cardinality) 下的 splits 数 R
        self.drop_block = drop_block

        self.conv1 = nn.Conv2d(inplanes, group_width, kernel_size=1, bias=False)
        self.bn1 = norm_layer(group_width)
        self.act1 = act_layer(inplace=True)
        self.avd_first = nn.AvgPool2d(3, avd_stride, padding=1) if avd_stride > 0 and avd_first else None

        # 每个基数组 (cardinality) 下的 splits 数 R
        if self.radix >= 1:
            self.conv2 = SplitAttn(
                group_width, group_width, kernel_size=3, stride=stride, padding=first_dilation,
                dilation=first_dilation, groups=cardinality, radix=radix, norm_layer=norm_layer, drop_block=drop_block)
            self.bn2 = nn.Identity()
            self.act2 = nn.Identity()
        else:
            self.conv2 = nn.Conv2d(
                group_width, group_width, kernel_size=3, stride=stride, padding=first_dilation,
                dilation=first_dilation, groups=cardinality, bias=False)
            self.bn2 = norm_layer(group_width)
            self.act2 = act_layer(inplace=True)

        self.avd_last = nn.AvgPool2d(3, avd_stride, padding=1) if avd_stride > 0 and not avd_first else None

        self.conv3 = nn.Conv2d(group_width, planes * 4, kernel_size=1, bias=False)
        self.bn3 = norm_layer(planes*4)
        self.act3 = act_layer(inplace=True)
        self.downsample = downsample

    def zero_init_last_bn(self):
        nn.init.zeros_(self.bn3.weight)

    def forward(self, x):
        shortcut = x  # 跳跃/残差连接

        # Conv + BN + ReLU
        out = self.conv1(x)
        out = self.bn1(out)
        if self.drop_block is not None:
            out = self.drop_block(out)
        out = self.act1(out)

        # AvgPool
        if self.avd_first is not None:
            out = self.avd_first(out)

        # SplitAttn + BN + ReLU
        out = self.conv2(out)
        out = self.bn2(out)
        if self.drop_block is not None:
            out = self.drop_block(out)
        out = self.act2(out)

        # AvgPool
        if self.avd_last is not None:
            out = self.avd_last(out)

        # Conv + BN
        out = self.conv3(out)
        out = self.bn3(out)
        if self.drop_block is not None:
            out = self.drop_block(out)

        if self.downsample is not None:
            shortcut = self.downsample(x)

        # Res
        out += shortcut

        out = self.act3(out)

        return out

6.3 ResNeSt 

# https://github.com/huggingface/pytorch-image-models/blob/4d9c3ae2fb7cc4739ec57d4c06254d2ffc7e2c89/timm/models/resnest.py

def _create_resnest(variant, pretrained=False, **kwargs):
    return build_model_with_cfg(
        ResNet, variant, pretrained,
        default_cfg=default_cfgs[variant],
        **kwargs)

@register_model
def resnest101e(pretrained=False, **kwargs):
    """ ResNeSt-101e model. Matches paper ResNeSt-101 model, https://arxiv.org/abs/2004.08955
     Since this codebase supports all possible variations, 'e' for deep stem, stem_width 64, avg in downsample.
    """
    model_kwargs = dict(
        block=ResNestBottleneck, layers=[3, 4, 23, 3],
        stem_type='deep', stem_width=64, avg_down=True, base_width=64, cardinality=1,
        block_args=dict(radix=2, avd=True, avd_first=False), **kwargs)
    return _create_resnest('resnest101e', pretrained=pretrained, **model_kwargs)
  • 24
    点赞
  • 107
    收藏
    觉得还不错? 一键收藏
  • 14
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值