📕 第5章 卷积神经网络
📄 一句话概括

1×1 卷积在通道方向做全连接:32 通道压到 8 通道只要 264 个参数;3×3 做同样的事要 2312 个。 深度可分离卷积把 3×3 拆成「逐通道 3×3」加「1×1 混合通道」:9216 个参数变成 1312 个, 只剩 14%。 im2col 把卷积变成矩阵乘:同样一次卷积,逐点循环 237 毫秒,im2col 加矩阵乘 0.1 毫秒, 结果最大差 2.2e-15。

📝 本节和前面几节的关系

前面十一节讲卷积「是什么」,这一节讲它「怎么算得快、算得省」。 5.9 节的全连接层参数爆炸,1×1 卷积和全局平均池化都是对付它的手段。 这一节在教材里属于「进一步详细资讯」,是选读,但工程上最实用。

一、1×1 卷积:在通道上做全连接

窗口只有一个像素,看起来什么都没做。但它跨通道做了一次加权求和。 32 通道进、8 通道出,参数是 1 乘 1 乘 32 乘 8 加 8,等于 264。 换成 3×3 就是 2312,正好差一个核面积。 用途是「先把通道压下来,再做 3×3」。这叫瓶颈结构,ResNet 的残差块里到处都是。 它还能在不改尺寸的前提下加一层非线性——因为后面还跟着一个 ReLU。

二、深度可分离卷积:把空间和通道拆开

标准 3×3 从 32 通道到 32 通道,参数 9216。 拆成两步:先逐通道 3×3(32 个核,每个只看自己那个通道,288 个参数), 再用 1×1 把 32 个通道混合成 32 个(1024 个参数)。合计 1312。 它假设「空间滤波」和「通道混合」可以分开做。 这个假设在多数情况下成立,所以 MobileNet 系列靠它把模型塞进手机。 代价是表达力弱一点。所以它是「用精度换体积」的手段,不是白捡的便宜。

三、im2col:把卷积变成矩阵乘

把每个窗口的像素按列摊开,凑成一个大矩阵,卷积就变成一个矩阵乘。 实测 8×8×32×32 的一次卷积:逐点循环 237 毫秒,im2col 加矩阵乘 0.1 毫秒。差两千多倍。 两者结果最大差 2.2e-15,只是浮点累加顺序不同,算子本身没变。 代价是内存:那个矩阵是原图的 8 倍。典型的时间换空间。

四、还有些什么

分组卷积:把通道分成几组,各组独立卷积。 AlexNet 当年是为了拆到两块 GPU 上,后来发现它顺带省参数,于是成了 ResNeXt 的核心。 膨胀卷积就是 5.6 节的空洞,语义分割里是主力。 可变形卷积让采样点自己学偏移,对不规则形状特别有效,代价是实现复杂。 卷积的花样很多,但核心只有一个:在某个邻域里共享一组权重。剩下的都是在这句话上做优化。

五、这一节的三个数字

264 对 2312:1×1 相对 3×3,同样压缩通道,省九倍。 1312 对 9216:可分离相对标准,14%。 0.1 毫秒对 237 毫秒:im2col 相对循环。 三个数字对应三种省法:省参数、省参数又省计算、省时间。工程上真正在用卷积时,这三件事天天要算。

六、动手 Demo:1×1、可分离、im2col

三件事各算一遍:1×1 卷积和 3×3 卷积压缩同样通道数,参数差多少;深度可分离卷积相对标准卷积省多少; im2col 相对逐点循环快多少。

p11 = 1 * 1 * 32 * 8 + 8          # 1×1 卷积,32 通道压到 8
p33 = 3 * 3 * 32 * 8 + 8          # 3×3 卷积,同样 32 压到 8
std = 3 * 3 * 32 * 32             # 标准 3×3,32 到 32
sep = 3 * 3 * 32 + 32 * 32        # 逐通道 3×3 加 1×1 混合

print("1×1 %d   3×3 %d" % (p11, p33))
print("标准 %d  可分离 %d  即 %.0f%%" % (std, sep, 100.0 * sep / std))

# im2col:把每个窗口摊成一列,卷积变成矩阵乘
M = np.array([x[:, i:i + 3, j:j + 3].ravel()
              for i in range(30) for j in range(30)]).T
Y = (K.reshape(8, 72) @ M + b.reshape(-1, 1))

实跑输出:

--------------------------------------------------------------------
卷积的进一步详细资讯:1×1、可分离、im2col
--------------------------------------------------------------------

  先说 1×1 卷积。它的窗口只有一个像素,看起来什么都没做,但它跨通道做了一次加权求和。

  核                作用              参数量
  --------------------------------------------
  1×1(32 到 8)    通道压缩          264
  3×3(32 到 8)    通道压缩加空间    2312

  **1×1 卷积就是「在通道这个方向上做全连接」。**
  它的用处是先把通道数压下来,再做 3×3,计算量能省一个数量级。

  再说深度可分离卷积:把一个 3×3 卷积拆成「逐通道 3×3」加「1×1 混合通道」两步。

  做法                  参数量      相对
  ------------------------------------------
  标准 3×3(32 到 32)  9216        100%
  深度可分离            1312        14%

  差不多是原来的七分之一。**手机上的模型基本都用它,因为参数量和计算量一起降。**

  最后说 im2col。循环版卷积慢,是因为 Python 每算一个点都要绕一圈。
  **把每个窗口在通道方向摊平成一列,卷积就变成了一个大矩阵乘。**

  做法              形状                        耗时
  ----------------------------------------------------------
  逐点循环          8×8×32×32 直接算            237 毫秒
  im2col 加矩阵乘   8×72 乘 72×900              0.1 毫秒

  两种做法算出来的东西一样:最大差 2.2e-15。**变的只是记账方式,算子本身没变。**

  **摊开之后,卷积就退化成一个标准的矩阵乘法,可以交给 BLAS 跑满 CPU。**
  caffe 当年就是靠这一手把卷积跑快的,现在的框架底层还是这个思路。
  代价是内存:那个矩阵有 72 乘 900 个元素,是原图元素数的 8 倍。
  **所以 im2col 是典型的「用内存换速度」。**

1×1 那一行:264 对 2312,比例是 8.75 倍,差不多就是核面积 9 倍的关系。(差的那些是偏置项。) 深度可分离:9216 变 1312,14%。这个 14% 可以算出来:1/9 加 1/32,正好约等于 0.142。 两个数字说明同一件事:卷积的开销是「核面积 × 输入通道 × 输出通道」,把任何一个因子拆开都能省。 im2col 那一行最戏剧:237 毫秒对 0.1 毫秒,差两千多倍,而两者算的是同一个东西,最大差 2.2e-15。 差距来自哪里?循环版每一格都要回到 Python 解释器走一遍; 矩阵乘交给了编译好的 BLAS,一次调用跑完几十万次乘加。这就是「把热循环交给 C」的威力。 代价是内存 8 倍。 所以显存紧张时会退化成「分块 im2col」,一块一块摊。所有工程优化最后都是这个形状:换一点内存, 换很多时间。

30 秒实验

  1. 如果 3×3 换成 5×5,标准卷积和可分离卷积的比例会变成多少?
  2. 为什么 im2col 的矩阵元素数是原图的 9 倍?(提示:每个位置都要存 9 个数)
  3. 分组卷积分成 4 组,参数量会变成原来的几分之几?

常见误区

  1. 以为 1×1 卷积「什么都没做」。它在通道方向做了一次完整的线性变换, 参数量是 32 乘 8,不是零。
  2. 以为可分离卷积没有代价。表达力确实弱一些,所以在精度要求高的场景要谨慎评估。
  3. 以为 im2col 一定更快。它吃内存。显存不够时,分块加循环的混合实现反而更快。
自测三问(点开对答案) 1. 1×1 卷积的作用是什么? —— 在通道方向做线性组合,常用来压缩或扩张通道数,也可以在不改尺寸的前提下加非线性。 2. 深度可分离卷积省在哪? —— 把空间滤波和通道混合拆成两步,参数量约降到原来的七分之一(1/9 加 1/32)。 3. im2col 为什么快? —— 把卷积变成一个大矩阵乘,交给高度优化的 BLAS 库执行。代价是要多占几倍内存。

第 5 章到此结束。卷积解决了「空间结构」,但它假设输入是固定大小的图。 如果输入是一句话、一段语音,长度可变、前后有顺序,那就是第 6 章的事。

相关笔记