一、论文基本信息
论文题目:Channel Pruning for Accelerating Very Deep Neural Networks
作者:Yihui He、Xiangyu Zhang、Jian Sun
发表信息:ICCV 2017
论文链接:
-
arXiv:Channel Pruning for Accelerating Very Deep Neural Networks
-
CVF Open Access:ICCV 2017 论文页面
复现代码:https://download.csdn.net/download/qq_36581957/93084335
这篇论文发表于 ICCV 2017,论文页显示其收录于 Proceedings of the IEEE International Conference on Computer Vision (ICCV), 2017, pp. 1389--1397 。论文提出了一种面向 very deep CNN 的通道剪枝方法,核心流程是:对每一层先用 LASSO regression 选择重要输入通道,再用 least square reconstruction 重建当前层卷积权重。
从研究脉络上看,这篇论文和 ThiNet 非常接近。ThiNet 通过下一层输出重构误差选择要删除的 channels,而这篇论文进一步把通道选择明确写成一个 稀疏回归问题:希望只使用少量输入 channels,就能尽量重构原始卷积层输出。
二、论文要解决的问题
在前面的 Pruning Filters for Efficient ConvNets 中,通道重要性由 filter 的 L1 范数决定。这个方法非常简单,但判断标准比较粗糙:filter 权重小不一定代表它不重要,filter 权重大也可能和其他通道高度冗余。
在 ThiNet 中,作者开始从"下一层输出重构"的角度判断通道是否重要:如果删除某些 channels 后,下一层输出仍然可以被较好重构,那么这些 channels 就可以删除。
这篇 Channel Pruning for Accelerating Very Deep Neural Networks 继续解决同一个核心问题:
给定一个已经训练好的 CNN,如何选择一部分输入 channels,使当前卷积层输出尽可能保持不变,同时真正减少后续计算量?
也就是说,剪枝不是简单排序,而是先用 LASSO 找出少量有用通道,再重新拟合保留下来的卷积权重,让剪枝后的层尽量逼近原始层输出。
三、核心思想
这篇论文的核心思想可以概括为一句话:
对于某一层卷积,只保留一部分输入 channels,并重新学习这一层的卷积权重,使剪枝后的输出尽量接近原始输出。
假设某一层卷积原来有 (c) 个输入通道。原始输出可以看作所有输入通道经过卷积后的加和:
其中:
-
:第 (i) 个输入 feature map;
-
:当前卷积层中连接第 (i) 个输入通道的卷积核;
-
Y:当前卷积层的输出 feature map;
-
(*):卷积操作。
剪枝后,只希望保留一部分输入通道:
其中 (S) 是被保留的通道集合, 是剪枝后重新重建的卷积权重。
因此,问题变成两个子问题:
第一步:选哪些 channels?
第二步:选完 channels 后,如何调整剩余卷积核权重?
论文对应的答案是:
第一步:用 LASSO regression 做通道选择。
第二步:用 least square reconstruction 重建权重。
论文摘要中也明确指出,该方法是一个 iterative two-step algorithm,由 LASSO regression based channel selection 和 least square reconstruction 两部分组成。
四、方法细节
4.1 剪枝对象:当前层的输入 channels
这篇论文的视角和上一篇 ThiNet 略有不同。
ThiNet 常被理解为:
剪第 i 层 filters
↓
等价于删除第 i+1 层输入 channels
↓
看第 i+1 层输出能否被重构
而这篇论文直接从某一卷积层本身出发:
当前卷积层有很多输入 channels
↓
选择一部分输入 channels 保留
↓
删除未保留的输入 channels
↓
对应地,上一层输出 channels / filters 也被删除
所以它本质上仍然是 channel pruning,只是优化目标写在"当前层输出重构"上。
4.2 原始卷积输出表示
设某个卷积层的输入为:
卷积核为:
输出为:
其中:
-
(c):输入通道数;
-
(n):输出通道数;
-
:卷积核大小;
-
(h,w):输入特征图空间尺寸;
-
(h',w'):输出特征图空间尺寸。
原始卷积可以写成:
通道剪枝希望只保留 (c') 个输入通道,其中:
因此剪枝后的目标是:
4.3 通道选择:LASSO regression
理想情况下,我们希望求解:
并且约束:
这里 表示非零元素个数,也就是保留下来的通道数量。
但是约束是离散组合优化问题,直接求解很困难。因此论文使用 LASSO 回归来近似这个选择过程:
其中:
-
:第 (i) 个通道的选择系数;
-
:稀疏正则强度;
-
:L1 正则项,用来让一部分
变成 0 或接近 0。
LASSO 的作用就是:
让少数通道的系数保持非零,
让冗余通道的系数趋近于 0。
因此,通道选择可以通过观察 (\beta_i) 是否为非零来完成:
其中 (S) 就是保留通道集合。
4.4 为什么 LASSO 可以用来做通道选择?
LASSO 的本质是带 L1 正则的线性回归。L1 正则有一个重要性质:它会产生稀疏解。
在普通最小二乘中,所有通道都可能被赋予一个非零权重。但加入 L1 正则后,一些不重要或可替代的通道系数会被压到 0。
对于通道剪枝来说,这正好符合需求:
β_i 不为 0:说明第 i 个通道被使用,保留;
β_i 等于 0:说明第 i 个通道被舍弃,删除。
所以,这篇论文不是手工定义"通道重要性分数",而是让优化问题自动告诉我们哪些通道对重构输出更重要。
4.5 权重重建:least square reconstruction
LASSO 的目标主要是选择通道,但 LASSO 解出来的 (\beta) 并不一定是最终最好的卷积权重。
因此论文在得到保留通道集合 (S) 后,会固定通道集合,只重新求解卷积权重:
这是一个最小二乘重建问题。
它的作用是:
通道已经选好了,
现在重新拟合保留通道上的卷积核,
让剪枝后的输出尽量接近原始输出。
这一步非常关键。因为如果只是删除通道,然后直接保留原始权重,输出误差可能比较大。最小二乘重建相当于给剪枝后的层做了一次局部修复,让剩余通道承担被删除通道的一部分表达功能。
4.6 两步交替优化
论文的方法可以理解为一个两步过程:
Step 1:Channel selection
固定原始卷积权重 W,
用 LASSO 求 β,
根据 β 的非零位置选择保留 channels。
Step 2:Feature reconstruction
固定保留通道集合 S,
用 least square 重新求解卷积权重 W,
让剪枝后的层输出逼近原始输出。
在实际实现中,(\lambda) 通常需要调整,使 LASSO 选出的非零通道数量接近目标保留数量 (c')。
4.7 为什么不是只做 LASSO?
只做 LASSO 的问题是,它主要负责"选择哪些通道",但并不一定给出最适合剪枝后网络的卷积核参数。
因为剪枝后,网络结构已经变了:
原来:
输入 c 个 channels,输出 n 个 channels
剪枝后:
输入 c' 个 channels,输出 n 个 channels
这时原始卷积核只是在旧结构下训练得到的。删除一部分输入通道后,剩余通道的卷积核最好重新拟合一下。
所以论文采用:
LASSO 负责选通道
Least Square 负责修权重
这个组合是整篇论文的核心。
4.8 多层剪枝
如果一层一层单独剪,很容易出现误差累积问题:
第 1 层剪枝产生误差
↓
第 2 层输入分布改变
↓
第 2 层再剪枝又产生误差
↓
误差逐层累积
论文提出将方法推广到多层情况,以减少 accumulated error。CVF 论文摘要也指出,作者进一步将算法推广到 multi-layer 和 multi-branch 情况,从而降低累计误差并增强对不同网络结构的兼容性。
直观理解是:剪枝时不能只看单层的局部输出,还要考虑多个连续层之间的输出一致性。对于 very deep networks,这一点尤其重要,因为网络层数越深,单层误差越容易向后传播并放大。
4.9 多分支结构:ResNet、Xception
VGG 这种串行网络比较容易剪,因为它是一条链式结构:
conv1 -> conv2 -> conv3 -> conv4 ...
但是 ResNet 有 residual branch 和 shortcut branch,Xception 也有更复杂的分支结构。如果不同分支的通道剪得不一致,可能导致后续相加或拼接时维度对不上。
因此,这篇论文专门强调要处理 multi-branch cases。论文摘要中也指出该方法被推广到多分支结构,并能加速 ResNet、Xception 等现代网络。
对于 ResNet,核心约束是:
参与 residual addition 的分支,输出通道必须一致。
所以剪枝时要么只剪 residual block 内部的中间通道,要么在多个分支之间使用一致的通道选择策略。
五、关键公式
5.1 原始卷积输出
表示当前层输出由所有输入 channels 的卷积结果相加得到。
5.2 带通道选择变量的剪枝输出
其中:
表示删除第 (i) 个输入通道。
5.3 理想通道选择目标
该目标希望在最多保留 (c') 个 channels 的条件下,最小化剪枝前后输出差异。
5.4 LASSO 近似
这里用 L1 正则代替难求解的 L0 约束,使通道选择问题变成稀疏回归问题。
5.5 最小二乘重建
通道集合 (S) 确定后,重新求解保留通道上的卷积权重:
这一步用于降低剪枝后的局部重构误差。
python
"""
Channel Pruning toy demo:
LASSO channel selection + least-squares weight reconstruction.
对应论文:
Channel Pruning for Accelerating Very Deep Neural Networks
Yihui He, Xiangyu Zhang, Jian Sun, ICCV 2017
这份代码不是论文的完整工程复现,而是"理解性代码":
1. 构造一个 5 层 CNN。
2. 随机生成一组输入数据。
3. 以 conv3 为目标层,选择 conv3 的输入 channels。
4. 使用 LASSO 得到每个输入 channel 的选择系数 beta。
5. 根据 |beta| 保留 Top-K channels。
6. 使用最小二乘重新拟合 conv3 的卷积权重。
7. 物理删除 conv2 的输出 filters 和 conv3 的输入 channels。
8. 对比剪枝前后参数量、局部重构误差、最终输出误差。
为什么剪 conv3 的输入 channels 等价于剪 conv2 的 filters?
conv2 的输出正好是 conv3 的输入。
如果删除 conv3 的某个输入 channel,
那么 conv2 中产生这个 channel 的 filter 也必须删除。
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Tuple
import torch
# 让 demo 在普通 CPU 环境中运行更稳定,避免小矩阵运算触发过多线程开销。
torch.set_num_threads(1)
import torch.nn as nn
import torch.nn.functional as F
# ============================================================
# 1. 一个 5 层卷积网络
# ============================================================
class Tiny5ConvNet(nn.Module):
"""
一个非常小的 5 层 CNN,用于理解 channel pruning。
默认结构:
conv1: 3 -> 8
conv2: 8 -> 12
conv3: 12 -> 16
conv4: 16 -> 16
conv5: 16 -> 10
本 demo 中我们剪 conv3 的输入 channels。
因为 conv3 的输入来自 conv2 的输出,所以最终会让:
conv2: 8 -> 12 变成 8 -> K
conv3: 12 -> 16 变成 K -> 16
"""
def __init__(
self,
c1: int = 8,
c2: int = 12,
c3: int = 16,
c4: int = 16,
c5: int = 10,
) -> None:
super().__init__()
self.conv1 = nn.Conv2d(3, c1, kernel_size=3, padding=1, bias=True)
self.conv2 = nn.Conv2d(c1, c2, kernel_size=3, padding=1, bias=True)
self.conv3 = nn.Conv2d(c2, c3, kernel_size=3, padding=1, bias=True)
self.conv4 = nn.Conv2d(c3, c4, kernel_size=3, padding=1, bias=True)
self.conv5 = nn.Conv2d(c4, c5, kernel_size=3, padding=1, bias=True)
def forward_until_conv3_input(self, x: torch.Tensor) -> torch.Tensor:
"""
返回 conv3 的输入。
也就是:
x -> conv1 -> ReLU -> conv2 -> ReLU
shape:
输入 x: [B, 3, H, W]
conv1 + ReLU 后: [B, 8, H, W]
conv2 + ReLU 后: [B, 12, H, W]
返回值就是 conv2 的输出,也就是 conv3 的输入。
"""
x = F.relu(self.conv1(x))
x = F.relu(self.conv2(x))
return x
def forward_from_conv3_input(self, x: torch.Tensor) -> torch.Tensor:
"""
从 conv3 的输入继续前向。
shape:
x: [B, C2, H, W]
conv3 + ReLU 后: [B, 16, H, W]
conv4 + ReLU 后: [B, 16, H, W]
conv5 后: [B, 10, H, W]
"""
x = F.relu(self.conv3(x))
x = F.relu(self.conv4(x))
x = self.conv5(x)
return x
def forward(self, x: torch.Tensor) -> torch.Tensor:
x = self.forward_until_conv3_input(x)
x = self.forward_from_conv3_input(x)
return x
# ============================================================
# 2. 工具函数:参数量统计和结构打印
# ============================================================
def count_params(model: nn.Module) -> int:
"""统计模型参数量。"""
return sum(p.numel() for p in model.parameters())
def print_model_structure(model: Tiny5ConvNet, title: str) -> None:
"""打印 5 个卷积层的输入输出通道数。"""
print(f"\n[{title}]")
for name in ["conv1", "conv2", "conv3", "conv4", "conv5"]:
conv = getattr(model, name)
print(f"{name}: {conv.in_channels:2d} -> {conv.out_channels:2d}")
print(f"params: {count_params(model):,}")
# ============================================================
# 3. 构造 LASSO 的回归数据
# ============================================================
@torch.no_grad()
def build_lasso_regression_data(
conv_input: torch.Tensor,
conv: nn.Conv2d,
max_samples: int = 12000,
seed: int = 0,
) -> Tuple[torch.Tensor, torch.Tensor]:
"""
构造 LASSO 回归数据 X_lasso 和 y_lasso。
目标:
对 conv 的输入 channels 做选择。
原始卷积输出可以写成:
y = sum_c contribution_c + bias
这里为了选择输入通道,把每个输入 channel 的贡献单独算出来:
contribution_c =
输入第 c 个 channel 的局部 patch
与
conv 中第 c 个输入通道对应的卷积核
做内积
对每个输出位置、每个输出通道,都可以得到一条样本:
[通道0贡献, 通道1贡献, ..., 通道C-1贡献] -> 原始输出去掉 bias
返回:
X_lasso: [N, C]
N 是采样出来的回归样本数;
C 是 conv 的输入通道数。
每一列表示一个输入 channel 的贡献。
y_lasso: [N]
原始 conv 输出去掉 bias 后的标量值。
在本 demo 中:
conv = conv3
conv_input = conv3 的输入 = conv2 的输出
C = 12
"""
if conv.groups != 1:
raise ValueError("This toy demo only supports standard Conv2d with groups=1.")
device = conv_input.device
B, C, H, W = conv_input.shape
O = conv.out_channels
k_h, k_w = conv.kernel_size
k2 = k_h * k_w
# --------------------------------------------------------
# F.unfold 会把卷积滑动窗口摊开。
#
# conv_input: [B, C, H, W]
#
# 对 3x3 卷积来说,每个空间位置会取一个:
# [C, 3, 3]
# 的局部 patch。
#
# unfold 后:
# patches: [B, C * 3 * 3, L]
# 其中 L 是输出特征图的空间位置数,通常是 H_out * W_out。
# --------------------------------------------------------
patches = F.unfold(
conv_input,
kernel_size=conv.kernel_size,
dilation=conv.dilation,
padding=conv.padding,
stride=conv.stride,
)
# 变成 [B, L, C, K*K],方便按输入 channel 计算贡献。
patches = patches.view(B, C, k2, -1).permute(0, 3, 1, 2).contiguous()
# conv.weight 原始 shape:
# [O, C, K, K]
# reshape 后:
# [O, C, K*K]
weight = conv.weight.detach().view(O, C, k2)
# --------------------------------------------------------
# 计算每个输入 channel 对每个输出值的贡献:
#
# patches: [B, L, C, K*K]
# weight: [O, C, K*K]
#
# einsum 结果:
# contributions: [B, L, O, C]
#
# contributions[b, l, o, c] 表示:
# 第 b 张图像、
# 第 l 个空间位置、
# 第 o 个输出通道、
# 第 c 个输入通道
# 对 conv 输出的贡献。
# --------------------------------------------------------
contributions = torch.einsum("blck,ock->bloc", patches, weight)
# y_no_bias 是原始 conv 输出去掉 bias 后的值。
# 因为:
# conv_output_no_bias = sum_c contributions_c
# shape:
# [B, L, O]
y_no_bias = contributions.sum(dim=-1)
# 将 [B, L, O, C] 拉平成 [B*L*O, C]
X_lasso = contributions.reshape(-1, C)
# 将 [B, L, O] 拉平成 [B*L*O]
y_lasso = y_no_bias.reshape(-1)
# 随机采样一部分回归样本,避免矩阵太大。
N = X_lasso.shape[0]
if max_samples is not None and N > max_samples:
generator = torch.Generator(device=device)
generator.manual_seed(seed)
idx = torch.randperm(N, generator=generator, device=device)[:max_samples]
X_lasso = X_lasso[idx]
y_lasso = y_lasso[idx]
return X_lasso.cpu(), y_lasso.cpu()
# ============================================================
# 4. LASSO:用稀疏回归选择输入通道
# ============================================================
def soft_threshold(x: torch.Tensor, threshold: float) -> torch.Tensor:
"""
LASSO 中常用的 soft-thresholding 算子。
作用:
如果某个系数绝对值很小,就把它压成 0;
如果绝对值较大,就缩小一点。
公式:
S(z, t) = sign(z) * max(|z| - t, 0)
"""
return torch.sign(x) * torch.clamp(torch.abs(x) - threshold, min=0.0)
def lasso_ista(
X: torch.Tensor,
y: torch.Tensor,
alpha_ratio: float = 0.03,
num_iters: int = 300,
eps: float = 1e-8,
) -> torch.Tensor:
"""
用 ISTA 算法求解一个简单 LASSO:
min_beta 1/(2N) * ||X beta - y||_2^2 + alpha * ||beta||_1
这里 beta 的长度等于输入 channel 数。
beta_i 越大,说明第 i 个 channel 对重构输出越重要。
参数:
X: [N, C]
每个 channel 对输出的贡献。
y: [N]
原始输出去掉 bias 后的标量值。
alpha_ratio:
alpha = alpha_ratio * alpha_max。
alpha 越大,beta 越稀疏。
本 demo 最后还会根据 |beta| 取 top-k,所以不用严格调到刚好 K 个非零。
num_iters:
ISTA 迭代次数。
返回:
beta: [C]
每个输入通道的 LASSO 系数。
"""
X = X.float()
y = y.float()
# 标准化 X 和 y,可以让 LASSO 优化更稳定。
# 注意:通道选择只看 beta 的相对大小。
x_mean = X.mean(dim=0, keepdim=True)
x_std = X.std(dim=0, keepdim=True).clamp_min(eps)
Xs = (X - x_mean) / x_std
y_mean = y.mean()
ys = y - y_mean
N, C = Xs.shape
# alpha_max 是让所有 beta 都变成 0 的临界值。
# 取它的一小部分作为实际 alpha。
alpha_max = torch.max(torch.abs(Xs.T @ ys)) / max(N, 1)
alpha = alpha_ratio * alpha_max.item()
# Lipschitz 常数 L = ||X^T X||_2 / N。
# C 很小,因此这样写足够清楚。
gram = (Xs.T @ Xs) / N
L = torch.linalg.norm(gram, ord=2).item() + eps
step = 1.0 / L
beta = torch.zeros(C, dtype=torch.float32)
for _ in range(num_iters):
# 梯度:1/N * X^T (X beta - y)
grad = (Xs.T @ (Xs @ beta - ys)) / N
# ISTA = 梯度下降一步 + soft-thresholding
beta = soft_threshold(beta - step * grad, step * alpha)
# 这里的 beta 是标准化空间里的系数;
# 用于"通道排序"是可以的。
return beta
def select_channels_by_lasso(
X_lasso: torch.Tensor,
y_lasso: torch.Tensor,
keep_ratio: float = 0.5,
alpha_ratio: float = 0.03,
num_iters: int = 300,
) -> Tuple[torch.Tensor, torch.Tensor]:
"""
通过 LASSO 选择要保留的输入通道。
步骤:
1. 用 LASSO 得到每个通道的 beta。
2. 按 |beta| 从大到小排序。
3. 保留 Top-K 个通道。
为什么不是直接保留 beta 非零的通道?
论文中通常通过调节 lambda 控制保留通道数。
这份理解代码为了简单和稳定,使用:
LASSO 给重要性系数
Top-K 固定保留数量
这样更容易复现实验行为。
返回:
keep_indices: [K]
保留通道编号。
beta: [C]
LASSO 系数。
"""
C = X_lasso.shape[1]
keep_count = max(1, int(round(C * keep_ratio)))
beta = lasso_ista(
X_lasso,
y_lasso,
alpha_ratio=alpha_ratio,
num_iters=num_iters,
)
keep_indices = torch.topk(torch.abs(beta), k=keep_count, largest=True).indices
keep_indices = torch.sort(keep_indices).values
return keep_indices.long(), beta
# ============================================================
# 5. 最小二乘重建卷积权重
# ============================================================
@torch.no_grad()
def reconstruct_conv_by_least_squares(
conv_input_keep: torch.Tensor,
target_output: torch.Tensor,
old_conv: nn.Conv2d,
max_positions: int = 20000,
seed: int = 0,
) -> Tuple[torch.Tensor, torch.Tensor]:
"""
固定保留下来的输入 channels,重新拟合当前卷积层的权重。
对应论文中的 least square reconstruction。
目标:
原始 conv 输出为 target_output。
剪枝后只剩 conv_input_keep 这些输入 channels。
我们希望求新的卷积权重 W_new 和 bias_new,使:
conv(conv_input_keep, W_new, bias_new)
≈
target_output
这是一个普通线性最小二乘问题。
参数:
conv_input_keep:
保留下来的输入特征,shape [B, C_keep, H, W]
target_output:
原始 conv 输出,shape [B, O, H_out, W_out]
old_conv:
原始卷积层。用它的 kernel_size、stride、padding 等配置。
max_positions:
最小二乘最多使用多少个空间位置样本。
返回:
new_weight:
重建后的卷积权重,shape [O, C_keep, K, K]
new_bias:
重建后的 bias,shape [O]
"""
B, C_keep, H, W = conv_input_keep.shape
O = old_conv.out_channels
k_h, k_w = old_conv.kernel_size
k2 = k_h * k_w
# 先把保留通道的输入 patch 展开。
# patches: [B, C_keep*K*K, L]
# A: [B*L, C_keep*K*K]
patches = F.unfold(
conv_input_keep,
kernel_size=old_conv.kernel_size,
dilation=old_conv.dilation,
padding=old_conv.padding,
stride=old_conv.stride,
)
A = patches.transpose(1, 2).reshape(-1, C_keep * k2).cpu().float()
# target_output: [B, O, H_out, W_out]
# Y: [B*L, O]
Y = target_output.permute(0, 2, 3, 1).reshape(-1, O).cpu().float()
# 随机采样一部分位置来做最小二乘。
N = A.shape[0]
if max_positions is not None and N > max_positions:
generator = torch.Generator(device="cpu")
generator.manual_seed(seed)
idx = torch.randperm(N, generator=generator)[:max_positions]
A = A[idx]
Y = Y[idx]
# 加一列全 1,用于拟合 bias。
ones = torch.ones(A.shape[0], 1, dtype=A.dtype)
A_aug = torch.cat([A, ones], dim=1)
# 求解:
# min_theta ||A_aug theta - Y||_2^2
# theta shape:
# [C_keep*K*K + 1, O]
theta = torch.linalg.lstsq(A_aug, Y).solution
weight_flat = theta[:-1, :] # [C_keep*K*K, O]
bias = theta[-1, :] # [O]
# 转成 Conv2d 需要的权重格式:
# [O, C_keep, K, K]
new_weight = weight_flat.T.reshape(O, C_keep, k_h, k_w).contiguous()
new_bias = bias.contiguous()
return new_weight, new_bias
# ============================================================
# 6. 物理构建剪枝后的新模型
# ============================================================
@torch.no_grad()
def build_pruned_model(
old: Tiny5ConvNet,
keep_indices: torch.Tensor,
new_conv3_weight: torch.Tensor,
new_conv3_bias: torch.Tensor,
) -> Tiny5ConvNet:
"""
根据保留通道,真正构建一个更窄的新模型。
原始结构:
conv1: 3 -> 8
conv2: 8 -> 12
conv3: 12 -> 16
conv4: 16 -> 16
conv5: 16 -> 10
如果 keep_indices 长度为 K,则新结构:
conv1: 3 -> 8
conv2: 8 -> K
conv3: K -> 16
conv4: 16 -> 16
conv5: 16 -> 10
注意:
1. conv2 的输出 filter 被删除;
2. conv3 的输入 channel 被删除;
3. conv3 的权重不是简单拷贝,而是最小二乘重建后的权重。
"""
keep = keep_indices.cpu().long()
new_c2 = len(keep)
new = Tiny5ConvNet(
c1=old.conv1.out_channels,
c2=new_c2,
c3=old.conv3.out_channels,
c4=old.conv4.out_channels,
c5=old.conv5.out_channels,
)
# conv1 没剪,直接复制。
new.conv1.weight.copy_(old.conv1.weight)
new.conv1.bias.copy_(old.conv1.bias)
# conv2 删除输出 filters。
# old.conv2.weight shape: [12, 8, 3, 3]
# old.conv2.weight[keep] shape: [K, 8, 3, 3]
new.conv2.weight.copy_(old.conv2.weight[keep])
new.conv2.bias.copy_(old.conv2.bias[keep])
# conv3 使用最小二乘重建后的新权重。
# new_conv3_weight shape: [16, K, 3, 3]
new.conv3.weight.copy_(new_conv3_weight)
new.conv3.bias.copy_(new_conv3_bias)
# conv4 和 conv5 不变。
# 因为 conv3 的输出通道数仍然是 16,后续层输入维度没有变化。
new.conv4.weight.copy_(old.conv4.weight)
new.conv4.bias.copy_(old.conv4.bias)
new.conv5.weight.copy_(old.conv5.weight)
new.conv5.bias.copy_(old.conv5.bias)
return new
# ============================================================
# 7. 一个完整的 Channel Pruning Demo
# ============================================================
@dataclass
class PruningResult:
pruned_model: Tiny5ConvNet
keep_indices: torch.Tensor
prune_indices: torch.Tensor
beta: torch.Tensor
local_mse_before_ls: float
local_mse_after_ls: float
final_output_mse: float
@torch.no_grad()
def channel_pruning_lasso_demo(
model: Tiny5ConvNet,
data: torch.Tensor,
keep_ratio: float = 0.5,
alpha_ratio: float = 0.03,
max_lasso_samples: int = 12000,
max_ls_positions: int = 20000,
seed: int = 0,
) -> PruningResult:
"""
演示 Channel Pruning 的完整流程。
目标层:
conv3
剪枝对象:
conv3 的输入 channels
等价结构变化:
删除 conv2 的部分输出 filters,
同时删除 conv3 的对应输入 channels。
"""
model.eval()
# Step 1:拿到 conv3 的输入和原始 conv3 输出。
conv3_input = model.forward_until_conv3_input(data)
original_conv3_output = model.conv3(conv3_input)
print("\nStep 1: collect conv3 input and original conv3 output")
print(f"conv3 input shape: {tuple(conv3_input.shape)}")
print(f"original conv3 output shape: {tuple(original_conv3_output.shape)}")
# Step 2:构造 LASSO 回归数据。
X_lasso, y_lasso = build_lasso_regression_data(
conv_input=conv3_input,
conv=model.conv3,
max_samples=max_lasso_samples,
seed=seed,
)
print("\nStep 2: build LASSO regression data")
print(f"X_lasso shape: {tuple(X_lasso.shape)} # [samples, input_channels]")
print(f"y_lasso shape: {tuple(y_lasso.shape)}")
# Step 3:LASSO 选择要保留的通道。
keep_indices, beta = select_channels_by_lasso(
X_lasso=X_lasso,
y_lasso=y_lasso,
keep_ratio=keep_ratio,
alpha_ratio=alpha_ratio,
)
all_indices = torch.arange(model.conv3.in_channels)
mask = torch.ones_like(all_indices, dtype=torch.bool)
mask[keep_indices] = False
prune_indices = all_indices[mask]
print("\nStep 3: LASSO channel selection")
print(f"LASSO beta: {beta.tolist()}")
print(f"keep indices: {keep_indices.tolist()}")
print(f"pruned indices: {prune_indices.tolist()}")
# Step 4:比较"只删通道、不重建权重"时的局部误差。
conv3_input_keep = conv3_input[:, keep_indices, :, :]
output_before_ls = F.conv2d(
conv3_input_keep,
model.conv3.weight[:, keep_indices, :, :],
model.conv3.bias,
stride=model.conv3.stride,
padding=model.conv3.padding,
dilation=model.conv3.dilation,
groups=1,
)
local_mse_before_ls = F.mse_loss(
output_before_ls,
original_conv3_output,
).item()
print("\nStep 4: local reconstruction before least squares")
print(f"local MSE before LS: {local_mse_before_ls:.8f}")
# Step 5:最小二乘重建 conv3 权重。
new_conv3_weight, new_conv3_bias = reconstruct_conv_by_least_squares(
conv_input_keep=conv3_input_keep,
target_output=original_conv3_output,
old_conv=model.conv3,
max_positions=max_ls_positions,
seed=seed,
)
output_after_ls = F.conv2d(
conv3_input_keep,
new_conv3_weight,
new_conv3_bias,
stride=model.conv3.stride,
padding=model.conv3.padding,
dilation=model.conv3.dilation,
groups=1,
)
local_mse_after_ls = F.mse_loss(
output_after_ls,
original_conv3_output,
).item()
print("\nStep 5: least-squares weight reconstruction")
print(f"new conv3 weight shape: {tuple(new_conv3_weight.shape)}")
print(f"new conv3 bias shape: {tuple(new_conv3_bias.shape)}")
print(f"local MSE after LS: {local_mse_after_ls:.8f}")
# Step 6:物理构建剪枝后的模型。
pruned_model = build_pruned_model(
old=model,
keep_indices=keep_indices,
new_conv3_weight=new_conv3_weight,
new_conv3_bias=new_conv3_bias,
).eval()
print("\nStep 6: build physically pruned model")
print_model_structure(pruned_model, "Pruned model")
# Step 7:比较整个网络最终输出差异。
original_final_output = model(data)
pruned_final_output = pruned_model(data)
final_output_mse = F.mse_loss(
pruned_final_output,
original_final_output,
).item()
print("\nStep 7: compare final network output")
print(f"original final output shape: {tuple(original_final_output.shape)}")
print(f"pruned final output shape: {tuple(pruned_final_output.shape)}")
print(f"final output MSE: {final_output_mse:.8f}")
return PruningResult(
pruned_model=pruned_model,
keep_indices=keep_indices,
prune_indices=prune_indices,
beta=beta,
local_mse_before_ls=local_mse_before_ls,
local_mse_after_ls=local_mse_after_ls,
final_output_mse=final_output_mse,
)
# ============================================================
# 8. 主函数
# ============================================================
def main() -> None:
torch.manual_seed(42)
# 随机生成一组"图片"。
# shape: [batch_size, channels, height, width]
# 这里是 8 张 32x32 RGB 图像。
data = torch.randn(8, 3, 32, 32)
# 构造原始 5 层 CNN。
model = Tiny5ConvNet().eval()
print_model_structure(model, "Original 5-conv model")
# 运行 LASSO channel pruning demo。
result = channel_pruning_lasso_demo(
model=model,
data=data,
keep_ratio=0.5, # conv3 输入通道从 12 保留到 6
alpha_ratio=0.03, # LASSO 稀疏强度
max_lasso_samples=6000,
max_ls_positions=8000,
seed=123,
)
print("\n[Summary]")
print(f"kept channels: {result.keep_indices.tolist()}")
print(f"pruned channels: {result.prune_indices.tolist()}")
print(f"local MSE before LS: {result.local_mse_before_ls:.8f}")
print(f"local MSE after LS: {result.local_mse_after_ls:.8f}")
print(f"final output MSE: {result.final_output_mse:.8f}")
print("\nKey idea:")
print(" LASSO chooses which input channels of conv3 to keep.")
print(" Least squares reconstructs conv3 weights after channel selection.")
print(" Physically, this removes conv2 output filters and conv3 input channels.")
if __name__ == "__main__":
main()
六、实验设置
论文主要在 VGG-16、ResNet、Xception 等网络上验证方法。论文摘要中明确提到,方法能够在 VGG-16 上达到 5× speed-up,并且只带来 0.3% error increase;在 ResNet 和 Xception 上也能实现约 2× 加速,并分别只损失 1.4% 和 1.0% accuracy。
官方代码仓库显示,作者释放了 VGG-16 5×、VGG-16 4×、ResNet-50 2×、Faster R-CNN 2×/4× 等压缩模型,并给出了对应速度和精度记录。
从实现角度看,官方仓库基于 Caffe,并依赖 scipy、sklearn、easydict 等工具;仓库 README 中也给出了下载原始 VGG-16 模型、启动 channel pruning、finetuning 和 testing 的流程。
七、实验结果解读
7.1 VGG-16:大幅加速,精度损失很小
论文中最亮眼的结果是 VGG-16。摘要给出的结论是:剪枝后的 VGG-16 达到 5× speed-up ,同时 error 只增加 0.3%。
这个结果说明两点。
第一,VGG-16 的卷积通道存在大量冗余。虽然 VGG-16 参数量很大,但更关键的是前中段卷积层也有明显的通道冗余。
第二,LASSO + 最小二乘重建确实比简单按权重大小排序更合理。因为它直接优化剪枝前后特征输出的一致性,而不是依赖人工定义的重要性指标。
7.2 ResNet:说明方法可以处理现代结构
相比 VGG,ResNet 更难剪,因为残差连接要求分支输出维度一致。
论文摘要中提到,该方法可以加速 ResNet,并在 2× speed-up 下只带来约 1.4% accuracy loss。
这个结果的意义很大。因为如果一个剪枝方法只能在 VGG 上有效,那么它的价值有限;VGG 本身冗余很大,也不是现代网络的主流结构。而 ResNet 是深层残差网络,剪枝时需要处理 shortcut、multi-branch、block 内部通道等结构约束。
因此,这篇论文的重要贡献之一就是:它不仅在串行 CNN 上有效,也尝试将通道剪枝推广到更复杂的深层网络。
7.3 Xception:验证对多分支 / 特殊卷积结构的适配能力
论文摘要还提到,方法可以加速 Xception,并在 2× speed-up 下约有 1.0% accuracy loss。
Xception 中存在 depthwise separable convolution,这类结构和普通 VGG 卷积不同。如果剪枝方法只适用于普通 dense convolution,那么很难迁移到 Xception。
这说明作者的 multi-layer / multi-branch generalization 是有必要的:现代网络结构越来越复杂,通道剪枝必须考虑网络拓扑,而不能只在单层上做孤立排序。
7.4 Faster R-CNN:剪枝不只用于分类
官方代码仓库中还释放了 Faster R-CNN 2× 和 4× 加速模型,README 中列出了对应检测结果记录。
这说明该方法不只局限于 ImageNet 分类网络,也可以迁移到目标检测框架中。对于模型剪枝研究来说,这一点很重要,因为真实部署场景往往不只是分类,还包括检测、分割、跟踪、多模态感知等更复杂任务。
八、方法优点
8.1 通道选择目标更明确
相比 L1 filter pruning,这篇论文没有简单地认为"权重小就是不重要",而是把问题写成:
保留哪些 channels,才能尽量重构原始输出?
这个目标和剪枝后的网络行为更加一致。
8.2 LASSO 自然适合通道选择
LASSO 的稀疏性非常适合 channel pruning。它不是为每个通道手工设计重要性分数,而是通过稀疏回归自动选择少量有效 channels。
这使方法具有较强的优化解释性。
8.3 最小二乘重建降低局部误差
剪掉通道后,如果直接使用原来的剩余权重,输出误差可能较大。
论文使用 least square reconstruction 对保留通道的卷积权重重新拟合,相当于在不进行完整训练的情况下,先对局部层输出进行一次修复。
这一步是它区别于很多简单剪枝方法的重要点。
8.4 能处理 deeper 和 multi-branch networks
论文不仅在 VGG-16 上验证,还强调推广到 ResNet、Xception 等现代结构。摘要中明确提到该算法被泛化到 multi-layer 和 multi-branch cases,以减少累计误差并增强结构兼容性。
这使它比只适配 VGG 的早期剪枝方法更有工程意义。
九、方法局限
9.1 需要数据采样
与 L1 filter pruning 不同,这篇论文需要输入数据来构造层输入 (X) 和输出 (Y),然后进行 LASSO 和最小二乘重建。
因此,它不是 data-free pruning。没有训练集或校准集时,方法会比较难直接使用。
9.2 LASSO 计算成本较高
每剪一层都要收集特征、构造回归问题、调节 (\lambda)、求解 LASSO,并进行最小二乘重建。
相比简单的 L1-norm 排序,这种方法更复杂,剪枝前的计算成本更高。
9.3 局部重构不等于全局最优
该方法主要保证某一层或局部多层的输出重构误差较小,但网络最终分类精度不一定完全由局部重构误差决定。
某些通道对当前层输出贡献不大,但可能对后续类别判别有重要影响。因此,局部 reconstruction objective 仍然不是全局任务 loss 的严格替代。
9.4 剪枝流程仍然依赖 fine-tuning
虽然 LASSO 和 least square reconstruction 能降低局部误差,但剪枝后通常仍然需要 fine-tuning 才能恢复最终精度。
官方代码 README 也给出了 finetuning 和 testing 流程。
9.5 对现代 Transformer 不直接适用
这篇论文主要面向 CNN channel pruning。对于 Transformer、ViT、LLM、VLM,剪枝对象可能变成 attention heads、MLP hidden neurons、tokens、layers 或 KV cache。
LASSO + 重构的思想仍然可以借鉴,但不能直接套用到所有现代模型结构上。
十、后续影响
这篇论文的影响主要体现在三个方面。
第一,它把 channel pruning 明确建模为 稀疏选择 + 特征重构。后续很多通道剪枝方法都沿用了类似思想,只是把 LASSO 换成了其他选择策略或搜索策略。
第二,它推动了剪枝方法从 VGG 这类简单串行网络走向 ResNet、Xception 等复杂结构。论文摘要中明确强调了 multi-layer 和 multi-branch generalization,这对后续结构化剪枝非常重要。
第三,它为后续自动化剪枝和硬件感知剪枝提供了基础。官方仓库 README 也提到后续工作 AMC 将 channel pruning 与 reinforcement learning 结合,用于移动设备上的模型压缩与加速。
从专栏脉络上看,这篇论文可以放在这里:
Pruning Filters for Efficient ConvNets
↓
ThiNet
↓
Channel Pruning for Accelerating Very Deep Neural Networks
↓
Network Slimming / FPGM / HRank / EagleEye
↓
AutoML-based pruning / hardware-aware pruning
↓
ViT / LLM / VLM structured pruning
如果说 Pruning Filters for Efficient ConvNets 解决的是"为什么要剪整个 filter/channel",ThiNet 解决的是"为什么要看下一层输出重构",那么这篇论文进一步回答了:
如何把通道选择和权重重建写成一个更明确的优化问题?
答案就是:
LASSO 选通道,Least Square 重建权重。
十一、一句话总结
《Channel Pruning for Accelerating Very Deep Neural Networks》将 CNN 通道剪枝建模为 LASSO 稀疏通道选择与最小二乘特征重建问题,是结构化剪枝中"优化驱动通道选择"路线的经典代表工作。