模型剪枝经典论文精读:Channel Pruning for Accelerating Very Deep Neural Networks

一、论文基本信息

论文题目:Channel Pruning for Accelerating Very Deep Neural Networks

作者:Yihui He、Xiangyu Zhang、Jian Sun

发表信息: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 selectionleast 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-layermulti-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,并依赖 scipysklearneasydict 等工具;仓库 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 稀疏通道选择与最小二乘特征重建问题,是结构化剪枝中"优化驱动通道选择"路线的经典代表工作。

相关推荐
njsgcs1 小时前
已知链中心距用节数算中间电机摆放位置 常量弧长 三链轮弦长模型算法
算法
2601_962440842 小时前
计算机毕业设计之jsp旅游网站管理系统
java·开发语言·算法·课程设计·旅游·推荐算法
Java小白笔记2 小时前
Codex 桌面应用设置功能完全指南
人工智能·opencv·目标检测·机器学习·计算机视觉·数据挖掘·语音识别
池塘的蜗牛3 小时前
AFDM波形基于DFT的通感一体化
算法
大鱼>3 小时前
Scikit-learn Pipeline:构建可复用的 ML 流水线
python·机器学习·scikit-learn
147API3 小时前
Claude global workspace 研究给智能体测试提了一个醒
人工智能·算法·机器学习
葫三生3 小时前
《论三生原理》与模糊数学的关联、异同、互补关系?
人工智能·科技·算法·机器学习·开源
我命由我123453 小时前
方差(实例实操、与标准差的区别)
java·数据结构·算法·数据分析·java-ee·intellij-idea·idea
闲研随记3 小时前
【文献阅读 ICML 2026】RL算法:Critique-GRPO
论文阅读·算法·llm·强化学习·rl·icml·grpo