剪枝与重参第一课:修剪结构和标准

修剪结构和标准

注意事项

一、2023/4/6更新

更新修剪标准相关内容

前言

手写AI推出的全新模型剪枝与重参课程。记录下个人学习笔记,仅供自己参考。

本次课程主要讲解剪枝的方法,包括非结构化剪枝和结构化剪枝以及修剪标准。

课程大纲可看下面的思维导图

在这里插入图片描述

1.非结构化剪枝

非结构化剪枝是指不按照某种固定的结构对神经网络进行裁剪,而是根据某些规则选择需要裁剪的神经元。

1.1 细粒度剪枝(fine-grained)

细粒度剪枝是指针对神经网络的每个权重进行剪枝,相对于结构剪枝而言,细粒度剪枝不会改变神经网络的结构。细粒度剪枝通过移除不重要的权重,可以达到减小神经网络模型大小的目的,从而提高模型的运行速度和存储效率。

下图说明了细粒度剪枝前后的变化,通过移除不重要的权重进行剪枝

在这里插入图片描述

在细粒度剪枝中,我们可以按比例裁剪卷积层的权重。具体来说,我们可以先获取卷积核权重张量的数据,并转为numpy数组,然后计算需要剪枝的权重数量,找到需要保留的权重的最小阈值,将小于阈值的权重置为0,并将剪枝后的权重转为torch张量并赋给卷积层的权重。

以下是一个细粒度剪枝的示例代码,其中我们定义了一个函数prune_conv_layer()来实现卷积层权重的剪枝,然后遍历整个神经网络模型,对每一层的权重进行剪枝。

import torch.nn as nn
import torch
import numpy as np

class Conv(nn.Module):
    def __init__(self, in_channels, out_channels, kernel_size, padding=1):
        super(Conv, self).__init__()
        self.conv = nn.Conv2d(in_channels, out_channels, kernel_size, padding=padding)
        self.bn   = nn.BatchNorm2d(out_channels) 
        self.relu = nn.ReLU(inplace=True) # ReLU激活函数,inplace=True表示直接修改输入的张量,而不是返回一个新的张量
    
    def forward(self, x):
        x = self.conv(x)
        x = self.bn(x)
        x = self.relu(x)
        return x

class Net(nn.Module):
    def __init__(self):
        super(Net, self).__init__()
        self.conv1 = Conv(3, 64, kernel_size=3, padding=1)
        self.conv2 = Conv(64, 64, kernel_size=3, padding=1)
        self.conv3 = Conv(64, 128, kernel_size=3, padding=1)
        self.conv4 = Conv(128, 128, kernel_size=3, padding=1)
        self.fc1 = nn.Linear(128 * 4 * 4, 1024)
        self.fc2 = nn.Linear(1024, 10)
    
    def forward(self, x):
        x = self.conv1(x) # 第一层卷积
        x = self.conv2(x) # 第二层卷积
        x = self.conv3(x) # 第三层卷积
        x = self.conv4(x) # 第四层卷积
        x = x.view(x.size(0), -1) # 展平
        x = self.fc1(x) # 第一个全连接层
        x = self.fc2(x) # 第二个全连接层
        return x

def prune_conv_layer(layer, prune_rate):
    """
    按比例裁剪卷积层的权重
    """
    if isinstance(layer, nn.Conv2d):
        weight = layer.weight.data.cpu().numpy() # 获取卷积核权重张量的数据,并转为numpy数组
        # print(weight.shape)
        num_weights = weight.size # 获取权重的总数量
        num_prune = int(num_weights * prune_rate) # 计算需要裁剪的权重数量
        flat_weights = np.abs(weight.reshape(-1)) # 展开并取绝对值
        threshold = np.sort(flat_weights)[num_prune] # 找到需要保留的权重的最小阈值
        weight[weight < threshold] = 0 # 将小于阈值的权重置为0
        layer.weight.data = torch.from_numpy(weight).to(layer.weight.device) # 将剪枝后的权重转为torch张量并赋给卷积层的权重

net = Net()
prune_rate = 0.2 # 裁剪比例
for layer in net.modules():
    prune_conv_layer(layer, prune_rate)

dummy_input = torch.randn(1, 3, 4, 4) # 构造一个形状为(1,3,4,4)的随机张量

with torch.no_grad():
    output = net(dummy_input)
print(output)

在上面的示例代码中,我们首先定义了一个拥有4个卷积层+2个全连接层的神经网络Net,然后定义了一个名为prune_conv_layer的函数,用于按照给定的比例裁剪卷积层的权重。具体地,它遍历了网络的所有层,对每一个nn.Conv2d层都执行了剪枝操作。在剪枝的过程中,我们首先将卷积核权重张量转为了numpy数组,并计算了需要剪枝的权重数量。接着,我们将权重张量展平并取绝对值,然后找到需要保留的权重的最小阈值。最后,我们将小于阈值的权重置为零,并将剪枝后的权重转为torch张量并赋给卷积层的权重。在剪枝的过程中,我们没有修改偏置项,因为一般来说偏置项数量较少,对整个模型的性能影响不大。

我画了一个简单的图示用来说明卷据权重剪枝的大致过程:

在这里插入图片描述

1.2 向量剪枝(vector-level)

向量剪枝是一种非结构化的剪枝方法,它是将某些列和行上的参数设置为0,从而将参数的数量减少到原来的一部分。

下图是向量剪枝的一个简单图示,在3x3的卷积核中将第1行和第1列的参数全部置为0

在这里插入图片描述

简单示例代码如下:

import numpy as np

np.random.seed(1)

def vector_pruning(matrix, idx):
    row, col = idx
    prune_matrix = matrix.copy()
    prune_matrix[row, :] = 0
    prune_matrix[:, col] = 0
    
    return prune_matrix

matrix = np.random.randn(3, 3)

idx  = (1, 1)

# prune the matrix
prune_matrix = vector_pruning(matrix, idx)
print(f"{matrix}\n\n")
print(f"{prune_matrix}")

输出如下:

在这里插入图片描述

有了上述简单的示例,我们来实现下对Net网络中的卷积核的权重进行向量剪枝,示例代码如下:

import torch
import torch.nn as nn

# 向量剪枝
def vector_pruning(matrix, idx):
    row, col = idx
    prune_matrix = matrix.copy()
    prune_matrix[row, :] = 0
    prune_matrix[:, col] = 0
    
    return prune_matrix

net = Net()

for layer in net.modules():
    if isinstance(layer, nn.Conv2d):
        weight = layer.weight.data.cpu().numpy()  # 获取卷积核权重张量的数据,并转为numpy数组
        num_filters, num_channels, filter_height, filter_width = weight.shape  # 获取卷积核的数量、通道数以及高度和宽度
        for i in range(num_filters):
            for j in range(num_channels):
                # 对每个卷积核的每个通道进行向量剪枝
                prune_idx = (1, 1)  # 剪枝行列索引
                weight[i, j] = vector_pruning(weight[i, j], prune_idx)  # 剪枝操作
        layer.weight.data = torch.from_numpy(weight).to(layer.weight.device)  # 将剪枝后的权重转为torch张量并赋给卷积层的权重

假设我们的卷积核shape为[128,64,3,3],其中128是卷积核的数量,64是输入通道数,3x3是卷积核的大小,在上述代码中我们会对128个卷积核中每个通道进行向量剪枝操作,并将第1行第1列的参数全部置为0。

这样,每个卷积核的每个通道都会进行向量剪枝操作,以达到减少网络参数的目的。需要注意的是,向量剪枝是一种非常简单的剪枝方法,仅仅是将某些权重设置为0。因此,它可能会导致权重矩阵的稀疏性,但不一定能带来较大的压缩效果。

拓展:明确卷积核中的几个概念,假设卷积核的shape为[128,64,3,3],一个卷积核指的是权重[128,64,3,3],其中包含了128个filter,每个filter都有一个大小为[64,3,3]的权重矩阵,用于对输入数据进行卷积操作。而kernel通常指卷积核中的小矩阵,也就是卷积运算的基本单位,即[3,3]得小矩阵。每个filter就是由整个channel的kernel组成。

可能还是有些抽象,看下面的起司面包你就了解了(😂),下面这一份起司面包就是一个filter,每一片起司面包就是一个kernel,在图中,一份起司面包是由5片起司面包组成的,即一个filter是由5个kernel组成的,也就是说channel通道数为5,假设每一片起司面包大小为3x3,那么kernel大小就为[3,3],filter大小就为[5,3,3],那么如果你非常喜欢吃起司面包,总共买了128份这样相同的起司面包(是个狠人😎),那么一个卷积核的shape就是这总共的128份起司面包即[128,5,3,3]

在这里插入图片描述

1.3 卷积核剪枝(kernel-level)

卷积核剪枝是非结构化剪枝的一种形式,它是指对卷积神经网络中的卷积核进行剪枝,即删除某些卷积核中的权重,以达到减少模型参数数量、减少计算量、提高模型运行效率等目的的一种技术。

一般来说,我们可以通过计算每个filter的L2范数,根据L2范数排序,选择剪枝比例最高的一定数量的filter。

示例代码如下:

import torch.nn as nn
import numpy as np
import torch

def prune_conv_layer(layer, prune_rate):
    """
    对卷积层进行剪枝,将一定比例的权重设置为0
    """
    if isinstance(layer, nn.Conv2d):
        weight = layer.weight.data.cpu().numpy() # 去到当前层的卷积核权重 ===> [128,64,3,3]
        num_weights = weight.size
        num_prune = int(num_weights * prune_rate)
        # 计算每个filter的L2范数
        norm_per_filter = np.sqrt(np.sum(weight**2, axis=(1, 2, 3)))
        # 根据L2范数排序,选择剪枝比例最高的一定数量的卷积核
        indices = np.argsort(norm_per_filter)[:num_prune]
        # 将这些kernel中的所有权重置为0
        weight[indices] = 0
        layer.weight.data = torch.from_numpy(weight).to(layer.weight.device)

在上面的示例代码中,我们需要先对卷积核中每个filter权重向量求取L2范数,得到每个filter的L2范数。然后将这些L2范数进行升序排序,选择L2范数比较小的filter进行剪枝,因为它们对输出结果的贡献更小,剪枝后对输出的影响也不明显。

下面是一个深入研究的简单示例,用来说明卷积核剪枝:

import numpy as np

# 构造4个1x3x3的filter
filter1 = np.array([[[0, 5, 2],
                     [3, 9, 10],
                     [6, 6, 14]]])

filter2 = np.array([[[5, 6, 8],
                     [3, 4, 0],
                     [0, 6, 12]]])

filter3 = np.array([[[2, 10, 9],
                     [7, 11, 5],
                     [0, 12, 5]]])

filter4 = np.array([[[6, 2, 8],
                     [9, 3, 8],
                     [4, 9, 3]]])

# 将4个filter拼接成一个卷积核
weight = np.stack([filter1, filter2, filter3, filter4], axis=0)

# 定义剪枝比例和要剪枝的数量
prune_rate = 2/3 # 要去掉这么多
num_prune = int(weight.shape[0] * prune_rate)

# 计算每个filter的L2范数
norm_per_filter = np.sqrt(np.sum(weight ** 2, axis=(1, 2, 3)))
print()
print(f"每个卷积核的L2范数: {norm_per_filter}")

# 根据L2范数排序,选择剪枝比例最高的一定数量的卷积核
indices = np.argsort(norm_per_filter)[:num_prune]
print(f"需要剪枝的filter索引: {indices}")

# 将这些filter中的所有权重置为0
weight[indices] = 0
print(f"剪枝后的权重矩阵: \n {weight}")

在这个示例中,我们定义了4个[1,3,3]大小的filter(即每份起司面包中只有一片3x3大小的起司面包),这4个filter组成了一个卷积核,我们通过对卷积核中的4个filter计算L2范数,排序,剪枝便可得到最终的权重。

输出如下:

在这里插入图片描述

拓展:L2范数

范数(Norm)是向量空间的一种函数,其用来衡量向量的大小。在数学上,范数是一种将向量映射到非负实数的函数,通常记作 ∥ x ∥ \|\boldsymbol{x}\| x。范数有很多种,例如L1数、L2范数等。其中L2范数又称为欧几里得范数,它是指向量各元素的平方和的平方根,即:(from wiki)
∥ x ∥ 2 : = x 1 2 + ⋯ + x n 2 . \|\boldsymbol{x}\|_2:=\sqrt{x_1^2+\cdots+x_n^2}. x2:=x12++xn2 .
其中, x \boldsymbol{x} x是一个向量, x i x_i xi是向量 x \boldsymbol{x} x中的第i个元素。L2范数在机器学习和深度学习中经常被用来作为模型的正则化项,以控制模型的复杂度,防止过拟合。

除了L2范数,L1范数也是比较常用的范数之一,它是指向量各元素的绝对值之和,即:
∥ x ∥ 1 : = ∣ x 1 ∣ + ⋯ + ∣ x n ∣ . \|\boldsymbol{x}\|_1:=|x_1|+\cdots+|x_n|. x1:=x1++xn∣.
相比于L2范数,L1范数可以使向量更加稀疏,适用于稀疏性较强的场景。

总的来说,范数是衡量向量大小的一种函数,常用的有L1范数和L2范数。在深度学习中,范数常被用来作为正则化项,控制模型的复杂度和防止过拟合。

2.结构化剪枝

结构化剪枝是一种卷积层结构进行剪枝的方法。它通过对不同维度上的元素进行聚合,以便在不破坏卷积层结构的情况下实现剪枝。

2.1 滤波器剪枝

2.2 通道剪枝

2.3 层剪枝

结构化剪枝可以按照不同的级别进行剪枝如kernelfilter等,kernel就是每片起司面包,filter就是每份起司面包。

整个示例代码如下:

import numpy as np
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D

def visualize_tensor(tensor, batch_spacing=3):
    fig = plt.figure()
    ax = fig.add_subplot(111, projection='3d')
    
    for batch in range(tensor.shape[0]):
        for channel in range(tensor.shape[1]):
            for i in range(tensor.shape[2]): # height
                for j in range(tensor.shape[3]): # width
                    x, y, z = j + (batch * (tensor.shape[3] + batch_spacing)), i, channel
                    color = 'red' if tensor[batch, channel, i, j] == 0 else 'gray'
                    ax.bar3d(x, z ,y,  1, 1, 1, shade=True, color=color, edgecolor="black",alpha=0.9)
                    

    ax.set_xlabel('Width')
    # ax.set_ylabel('B & C')
    ax.set_zlabel('Height')
    ax.set_zlim(ax.get_zlim()[::-1])
    ax.zaxis.labelpad = 15 # adjust z-axis label position
    plt.title("vector_level")
    plt.show()

def prune_conv_layer(conv_layer, prune_method, percentile=20, vis=True):
    # conv_layer 维度应该是 [128,64,3,3] ===> [batch,channel,height,width]
    
    pruned_layer = conv_layer.copy()

    if prune_method == "fine_grained":
        pruned_layer[np.abs(pruned_layer) < 0.05] = 0

    if prune_method == "vector_level":
        # Compute the L2 sum along the last dimension (w)
        l2_sum = np.linalg.norm(pruned_layer, axis=-1)
    
    if prune_method == "kernel_level":
        # 计算每个kernel的L2范数
        l2_sum = np.linalg.norm(pruned_layer, axis=(-2, -1))

    if prune_method == "filter_level":
        # 计算每个filter的L2范数
        l2_sum = np.sqrt(np.sum(pruned_layer**2, axis=(-3, -2, -1)))
    
    if prune_method == "channel_level":
        # 计算每个channel的L2范数
        l2_sum = np.sqrt(np.sum(pruned_layer**2, axis=(-4, -2, -1)))
        # add a new dimension at the front
        l2_sum = l2_sum.reshape(1, -1)  # equivalent to l2_sum.reshape(1, 10)
        
        # repeate the new dimension 8 times
        l2_sum = np.repeat(l2_sum, pruned_layer.shape[0], axis=0)
    
    
    # Find the threshold value corresponding to the bottom 0.1
    threshold = np.percentile(l2_sum, percentile)

    # Create a mask for rows with an L2 sum less than the threshold
    mask = l2_sum < threshold

    # Set rows with an L2 sum less than the threshold to 0
    print(pruned_layer.shape)
    print(mask.shape)
    print("===========================")
    pruned_layer[mask] = 0

    if vis:
        visualize_tensor(pruned_layer)

    return pruned_layer
    
tensor = np.random.uniform(low=-1, high=1, size=(3, 10, 4, 5))

# Prune the conv layer and visualize it
pruned_tensor = prune_conv_layer(tensor, "vector_level", vis=True)
# pruned_tensor = prune_conv_layer(tensor, "kernel_level", vis=True)
# pruned_tensor = prune_conv_layer(tensor, "filter_level", vis=True)
# pruned_tensor = prune_conv_layer(tensor, "channel_level", percentile=40, vis=True)

在上面的示例代码中,首先定义了一个可视化函数visualize_tensor,它将四维张量可视化为三位图形,其中每个元素表示为立方体的一部分。然后定义了一个名为prune_conv_layer的函数,该函数接收一个卷积层、剪枝方法以及剪枝比例作为输入,输出剪枝后的卷积层。该函数支持不同的剪枝方法,包括细粒度剪枝,向量级别剪枝,kernel级剪枝、filter级剪枝和通道级剪枝。每种剪枝方法都有不同的聚合方式,用于计算每个剪枝单元的剪枝程度。最后,该函数根据计算得到的阈值对卷积层进行剪枝,并可视化剪枝后的结果。

可视化的结果如下所示:

在这里插入图片描述

3.非结构化剪枝 vs. 结构化剪枝

学到这里,我怎么感觉我自己已经糊了(😵),我好像将二者混淆了,尤其是将非结构化剪枝中的卷积核剪枝和结构化剪枝中的滤波器剪枝,二者似乎无区别呀(😲),我们先来回顾下结构化剪枝和非结构化剪枝的定义(from chatGPT)

非结构化剪枝中,每个卷积核的所有权重都被看做一个整体,进行剪枝的时候会按照某种规则(如按大小排序或按概率随机)选取一定比例的权重进行剪枝,这样剪枝后的卷积核可能出现部分元素为0,也就是权重的稀疏化现象。这种稀疏化的剪枝方式没有考虑结构和权重的关系,因此称为非结构化剪枝

结构化剪枝中,对于每个卷积核的权重进行处理时会考虑它们内部的结构和关系,例如将一个卷积核中的所有权重分成若干组,每个组内的权重相互依存,不能单独剪枝,这样就保证了剪枝后的卷积核依然是有结构的,因此称为结构化剪枝结构化剪枝可以分为不同的级别,如kernel-level(对每个kernel进行剪枝)、filter-level(对每个filter进行剪枝)、channel-level(对每个channel进行剪枝)等。在不同的级别上进行剪枝,考虑的权重结构和关系不同,因此剪枝后的卷积核结构也会有所不同。

下面说下我个人的理解,不一定正确(😂)

非结构化剪枝和结构化剪枝的区别在于操作的粒度不同非结构剪枝是针对整个权重矩阵的每个元素进行剪枝,不考虑权重之间的位置关系,因此剪枝后的权重矩阵可能出现很多零散部分。而结构化剪枝是针对权重矩阵中的特定结构(如kernel、filter、channel等)进行剪枝,以保持权重矩阵的结构不变,剪枝后仍然具有原有的结构。

重点是结构二字,非结构化是指没有按照某个结构进行剪枝,而是按照某种规则(如大小、概率随机等),结构化则刚好相反,它是按照某种结构进行剪枝的,这些结构就是vectorkernelfilterchannel。还是拿起司面包🍞来说,假设其维度是[128,64,3,3],非结构化剪枝就是在这128份起司面包中的每一份中的每一片起司面包的中间元素置为0(假设设定的规则是这样的),那么结构化剪枝就是计算这128份起司面包的L2范数,将L2范数排序小于某个阈值的那份起司面包全部元素置为0(假设结构是filter)。非结构化剪枝就是有点无脑的感觉,不考虑什么结构,直接操作所有的元素,结构化剪枝就是考虑数据之间的相关性(或者说结构性),比如每一份起司面包应该是相关的(因为每一份都是被打包好的,也可以看出一个整体),那么就需要以每一份为单位来进行剪枝。

综上所述,那么我感觉整个修剪结构的目录应该是这样的(🤔)

  • 非结构化剪枝
    • 细粒度剪枝
  • 结构化剪枝
    • vector-level pruning
    • kernle-level pruning
    • filter-level pruning
    • channel-level pruning

4.修剪标准

修剪标准(pruning citerion)是指剪枝时所采用的判断依据,也是剪枝的关键。三种常见的修剪标准有:基于权重大小的修剪标准、基于梯度幅度修剪、基于梯度和权重大小的混合标准。

值得注意的是,这三种方法的最终剪枝对象都是权重,也就是卷积核权重。它们的不同之处在于,它们所依据的标准不同。例如,基于权重大小的剪枝标准可以是依据权重绝对值大小来进行修建的,而基于梯度幅度修剪可以是根据梯度的绝对值大小来进行修剪的。因此,这些方法都是对卷积核权重进行修剪,只是针对不同的标准进行修剪。

4.1 基于权重大小的修剪标准

基于权重大小的修剪标准(weight-based pruning citerion)即根据权重大小来决定哪些权重需要剪枝。权重越小,越容易被剪枝。前面提到的非结构化修剪和结构化修剪都是基于卷积核的权重进行修剪的,也就是基于权重大小的修剪标准。

4.2 基于梯度幅度修剪

基于梯度幅度修剪(gradient-based pruning citerion)即根据权重的梯度幅度来决定哪些权重需要剪枝。梯度幅度越小的权重,越容易被剪枝。

为什么我们不能仅仅依靠梯度幅度进行修剪?

这是因为一方面,只依靠梯度大小进行裁剪可能会导致丢失某些有用的信息,因为一些梯度较小但仍然重要的参数可能被错误地修剪掉。另一方面,仅仅使用梯度幅度进行修剪还会导致修剪后地模型结构变得更加稀疏,使得模型的性能和鲁棒性下降。因此,通常需要结合权重大小等其它因素一起考虑,综合考虑多种因素来进行修剪。

4.3 基于梯度和权重大小的混合标准

基于梯度和权重大小的混合标准(mixed pruning criterion)即根据权重大小和梯度幅度综合考虑来决定哪些权重需要剪枝。综合考虑权重大小和梯度幅度,可以更好地选择需要剪枝的权重,提高剪枝效果。

示例代码如下:

from re import X
import numpy as np
import torch

def prune_by_gradient_weight_product(model, pruning_rate):
    grad_weight_product_list = []
    for name, param in model.named_parameters():
        if 'weight' in name:
            # 计算梯度与权重的乘积
            grad_weight_product = torch.abs(param.grad * param.data)
            grad_weight_product_list.append(grad_weight_product)
    
    # 将所有的乘积值合并到一个张量中
    all_product_values = torch.cat([torch.flatten(x) for x in grad_weight_product_list])
    # 计算需要修剪的阈值
    threshold = np.percentile(all_product_values.cpu().detach().numpy(), pruning_rate)

    # 对权重进行修剪
    for name, param in model.named_parameters():
        if 'weight' in name:
            # 创建一个mask掩码,表示哪些权重应该保留
            mask = torch.where(torch.abs(param.grad * param.data) >= threshold, 1, 0)
            # 扩展掩码的形状,以便逐元素相乘
            mask = mask.expand_as(param.data)
            # 应用mask
            param.data *= mask.float()

# 示例:使用50%的修剪率对一个PyTorch模型进行修剪
pruning_rate = 50
model = torch.nn.Sequential(torch.nn.Linear(10, 5), torch.nn.ReLU(), torch.nn.Linear(5, 1))
input_tensor = torch.randn(1, 10) # 创建一个随机输入张量
output_tensor = model(input_tensor) # 前向传递
loss = torch.sum(output_tensor) # 定义一个虚拟损失
loss.backward()                 # 执行反向传递以计算梯度
prune_by_gradient_weight_product(model, pruning_rate) # 对模型进行修剪

在上面的基于梯度和权重混合标准示例代码中,其逻辑是:

  • 1.遍历模型中的所有参数,找到其中的权重参数,并计算它们的梯度和权重的乘积
  • 2.将所有权重参数的梯度和权重乘积合并到一个张量中
  • 3.计算需要修剪得阈值,这里使用numpy库中的percentile函数
  • 4.对权重进行修剪。根据阈值创建一个掩码,表示哪些权重应该保留,哪些应该剪枝

总结

本次课程学习了模型剪枝的方法,主要可分为非结构化和结构化剪枝,其中结构化剪枝又可以按照不同的结构(比如kernelfilterchannel)进行剪枝。同时学习了修剪标准,像非结构剪枝和结构化剪枝都是针对于卷积核进行剪枝的即基于权重大小的修剪标准,除此之外还有基于梯度幅度修剪以及混合标准。让我们期待下节课吧😉。

  • 16
    点赞
  • 37
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 5
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

爱听歌的周童鞋

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值