剪枝与重参第一课:修剪结构
目录
- 修剪结构
- 前言
- 1.非结构化剪枝
- 1.1 细粒度剪枝(fine-grained)
- 1.2 向量剪枝(vector-level)
- 1.3 卷积核剪枝(kernel-level)
- 2.结构化剪枝
- 2.1 滤波器剪枝
- 2.2 通道剪枝
- 2.3 层剪枝
- 3.非结构化剪枝 vs. 结构化剪枝
- 总结
修剪结构
前言
手写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}.
∥x∥2:=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|.
∥x∥1:=∣x1∣+⋯+∣xn∣.
相比于L2范数,L1范数可以使向量更加稀疏,适用于稀疏性较强的场景。
总的来说,范数是衡量向量大小的一种函数,常用的有L1范数和L2范数。在深度学习中,范数常被用来作为正则化项,控制模型的复杂度和防止过拟合。
2.结构化剪枝
结构化剪枝是一种卷积层结构进行剪枝的方法。它通过对不同维度上的元素进行聚合,以便在不破坏卷积层结构的情况下实现剪枝。
2.1 滤波器剪枝
2.2 通道剪枝
2.3 层剪枝
结构化剪枝可以按照不同的级别进行剪枝如kernel
、filter
等,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等)进行剪枝,以保持权重矩阵的结构不变,剪枝后仍然具有原有的结构。
重点是结构二字,非结构化是指没有按照某个结构进行剪枝,而是按照某种规则(如大小、概率随机等),结构化则刚好相反,它是按照某种结构进行剪枝的,这些结构就是vector
、kernel
、filter
、channel
。还是拿起司面包🍞来说,假设其维度是[128,64,3,3]
,非结构化剪枝就是在这128份起司面包中的每一份中的每一片起司面包的中间元素置为0(假设设定的规则是这样的),那么结构化剪枝就是计算这128份起司面包的L2范数,将L2范数排序小于某个阈值的那份起司面包全部元素置为0(假设结构是filter)。非结构化剪枝就是有点无脑的感觉,不考虑什么结构,直接操作所有的元素,结构化剪枝就是考虑数据之间的相关性(或者说结构性),比如每一份起司面包应该是相关的(因为每一份都是被打包好的,也可以看出一个整体),那么就需要以每一份为单位来进行剪枝。
综上所述,那么我感觉整个修剪结构的目录应该是这样的(🤔)
- 非结构化剪枝
- 细粒度剪枝
- 结构化剪枝
- vector-level pruning
- kernle-level pruning
- filter-level pruning
- channel-level pruning
总结
本次课程学习了模型剪枝的方法,主要可分为非结构化和结构化剪枝,其中结构化剪枝又可以按照不同的结构(比如
kernel
、filter
、channel
)进行剪枝,期待下节课。