📗 第3章 卷积神经网络
📄 一句话概括

卷积和池化之后是一个三阶张量,要摊平成一列才能接全连接。 36 个数接 10 类,权重 360 个加偏置 10 个,共 370 个——这是整个网络里参数最多的一层。 1x1 卷积其实就是「权重共享的全连接」:两种写法算出来的差是 4.441e-16。 同一个 4 乘 10 的矩阵在 9 个位置各用一次,参数从 370 降到 50。

📝 本节和前面几节的关系

卷积和池化把图变成了 (3, 3, 4) 这样的三阶张量。 全连接层只认向量,所以要先摊平。 摊平本身没有信息损失,但有一个隐含要求:顺序必须固定。 训练时怎么排,预测时就得怎么排,否则同一个位置对不上。 这一节还要澄清一个常被误解的东西:1x1 卷积。 它看起来像「什么都没做」,实际上是通道之间的全连接。

一、摊平:顺序就是约定

一个 (3, 3, 4) 的张量,摊平成一列 36 个数。 排法是「先按行、再按列、最后按通道」,也就是 4 个通道各摊成 9 个,再接起来。 这个顺序没有对错,只有「一致」。 PyTorch 和 TensorFlow 的默认顺序都是通道在最后,所以摊平的结果一样。 如果手工改了维度顺序又忘了改摊平,训练和预测就会不一致——这种 bug 极难查。

二、参数量:这一层是重灾区

输入 输出 权重 偏置 合计
36 10 360 10 370
36 128 4608 128 4736
576 10 5760 10 5770
输入从 36 涨到 576,参数量涨 16 倍——因为它是乘积关系。
这也是为什么卷积网络总是先把尺寸压小再摊平:不是为了省卷积的力气,是为了省这一层的参数。
VGG 那种网络里,最后三层全连接占了全网络参数的绝大部分,原因就在这里。
后来的网络改用全局平均池化,就是为了把这一大坨参数直接去掉。

三、1x1 卷积:通道之间的全连接

核是 1 乘 1,所以它只看一个像素,但同时看这个像素的所有通道。 对每个像素位置,它做的是:4 个通道的值加权求和,得到 10 个新值。 这和一个「输入 4、输出 10」的全连接层完全一样。 区别在于:全连接只在摊平之后做一次,1x1 卷积在每个位置都做一次,共用同一套权重。 实测两种写法的最大差异 4.441e-16,就是浮点乘加的舍入误差。 所以「1x1 卷积」不是「卷积的退化版」,它是「权重共享的全连接」。

四、它省了多少,为什么重要

全连接 36 到 10:370 个参数。 1x1 卷积 4 到 10:50 个参数。 少了 7.4 倍。 更关键的是:1x1 卷积的参数和输入尺寸无关。 输入是 3 乘 3,它用 50 个参数;输入换成 30 乘 30,它还是那 50 个。 全连接就做不到:输入一变,权重矩阵的形状就得变。 这就是「全卷积网络」能处理任意大小输入的原因。 检测、分割这类任务输入尺寸经常变,用全连接会直接卡死。

五、那为什么还要全连接层

因为 1x1 卷积只做「逐像素」的线性组合,它不混合不同位置的信息。 左上角和右下角在这层里永远碰不到一起。 摊平之后接全连接,任意两个位置都会连起来。 要判断「上面有个圈、下面有根竖线」这种组合,就需要这种全局的连接。 所以常见的折中是:卷积抽特征、池化降尺寸、最后接一两层全连接做判断。 现代的折中是:卷积抽特征、全局平均池化压成一个向量、再接一层全连接。

六、动手 Demo:摊平之后,1x1 卷积其实就是权重共享的全连接

这段代码造一个 (3, 3, 4) 的张量,先看摊平的顺序,再算两档全连接的参数量; 然后用两种写法做同一个运算:逐像素全连接和 1x1 卷积,比较结果的差异。 重点看三处。第二段 370 和 4736;第三段两种写法的差 4.441e-16;第四段 370 对 50。

# -*- coding: utf-8 -*-
import numpy as np

rg = np.random.default_rng(5)

print('[1] 池化之后拿到的是一个三阶张量')
h = w = 3
F = 4
A = rg.normal(0.0, 1.0, (h, w, F))
print('  这里的例子是 3 乘 3 乘 4 = %d 个数' % (h * w * F))
print('  摊平(flatten)就是把它按固定顺序排成一列:先按行、再按列、最后按通道')
print('  顺序必须固定,否则同一个位置在训练和预测时对不上')
flat = A.reshape(-1)
print('  摊平后长度 %d,前 8 个是 %s' % (len(flat), np.round(flat[:8], 3).tolist()))
print('')

print('[2] 接全连接的参数账单')
for n_out in (10, 128):
    npar = h * w * F * n_out + n_out
    print('  %d -> %d:权重 %d 个,偏置 %d 个,共 %d 个'
          % (h * w * F, n_out, h * w * F * n_out, n_out, npar))
print('  这一层几乎总是整个网络里参数最多的一层')
print('  输入从 36 涨到 576(比如 8 乘 8 乘 9)时,这一层的参数会涨 16 倍')
print('')

print('[3] 1x1 卷积其实就是「逐像素的全连接」')
W1 = rg.normal(0.0, 0.5, (F, 10))
b1 = rg.normal(0.0, 0.1, 10)
manual = np.zeros((h, w, 10))
for i in range(h):
    for j in range(w):
        manual[i, j] = A[i, j] @ W1 + b1
conv = A @ W1 + b1
print('  两种写法:一种是每个像素位置单独做一次全连接,一种是 1x1 卷积一次算完')
print('  结果的最大差异 %s' % ('%.3e' % float(np.abs(manual - conv).max())))
print('  所以 1x1 卷积不是「卷积的退化版」,它就是权重共享的全连接:')
print('  同一个 %d 乘 10 的矩阵,在 %d 个位置上各用一次。' % (F, h * w))
print('')

print('[4] 省了多少参数')
n_fc = h * w * F * 10 + 10
n_11 = F * 10 + 10
print('  全连接 36 -> 10:%d 个参数' % n_fc)
print('  1x1 卷积 4 -> 10:%d 个参数' % n_11)
print('  少了 %.1f 倍,而且输入尺寸变大时 1x1 卷积的参数一个都不变' % (n_fc / n_11))
print('')
print('  这就是「全卷积网络」能处理任意大小输入的原因:')
print('  全连接层要求输入尺寸固定,1x1 卷积不要求——尺寸变了权重还是那几十个。')
print('')

print('[5] 那为什么还要全连接层')
print('  因为 1x1 卷积只做「逐像素」的线性组合,它不混合不同位置的信息。')
print('  摊平之后接全连接,任意两个位置都会连起来,表达力更强。')
print('  所以常见的折中是:卷积抽特征、池化降尺寸、最后接一两层全连接做判断。')

实跑输出:

--------------------------------------------------------------------
全连接层:摊平之后,1x1 卷积其实就是权重共享的全连接
--------------------------------------------------------------------
[1] 池化之后拿到的是一个三阶张量
  这里的例子是 3 乘 3 乘 4 = 36 个数
  摊平(flatten)就是把它按固定顺序排成一列:先按行、再按列、最后按通道
  顺序必须固定,否则同一个位置在训练和预测时对不上
  摊平后长度 36,前 8 个是 [-0.802, -1.324, -0.248, 0.42, 1.136, 0.11, -0.553, -0.785]

[2] 接全连接的参数账单
  36 -> 10:权重 360 个,偏置 10 个,共 370 个
  36 -> 128:权重 4608 个,偏置 128 个,共 4736 个
  这一层几乎总是整个网络里参数最多的一层
  输入从 36 涨到 576(比如 8 乘 8 乘 9)时,这一层的参数会涨 16 倍

[3] 1x1 卷积其实就是「逐像素的全连接」
  两种写法:一种是每个像素位置单独做一次全连接,一种是 1x1 卷积一次算完
  结果的最大差异 4.441e-16
  所以 1x1 卷积不是「卷积的退化版」,它就是权重共享的全连接:
  同一个 4 乘 10 的矩阵,在 9 个位置上各用一次。

[4] 省了多少参数
  全连接 36 -> 10:370 个参数
  1x1 卷积 4 -> 10:50 个参数
  少了 7.4 倍,而且输入尺寸变大时 1x1 卷积的参数一个都不变

  这就是「全卷积网络」能处理任意大小输入的原因:
  全连接层要求输入尺寸固定,1x1 卷积不要求——尺寸变了权重还是那几十个。

[5] 那为什么还要全连接层
  因为 1x1 卷积只做「逐像素」的线性组合,它不混合不同位置的信息。
  摊平之后接全连接,任意两个位置都会连起来,表达力更强。
  所以常见的折中是:卷积抽特征、池化降尺寸、最后接一两层全连接做判断。

第一段那张摊平后的前 8 个数,可以和原张量对一下。 它就是第一行第一个像素的 4 个通道,接着是第一个像素右边的 4 个通道。 记住这个顺序,手写代码时才不会把维度搞反。 第二段那个 16 倍要说清楚:输入从 36 涨到 576 是 16 倍,参数量也跟着 16 倍。 所以「把特征图尺寸减半」这件事对全连接层来说是 4 倍的收益。 第三段的 4.441e-16 是双精度浮点的舍入误差,不是算法差异。 它证明两种写法在数学上是同一个运算。 这也是为什么「1x1 卷积」这个名字有点误导——它不是卷积,是共享的全连接。 第四段那个「输入尺寸变了参数不变」才是 1x1 卷积真正的价值。 它让网络可以吃任意大小的图,这是检测和分割任务的前提。 代码里 manual 用了双重循环逐像素算,conv 直接一次矩阵乘。 两者的差在小数点后十六位,说明广播机制没有引入额外误差。

30 秒实验

  1. 把张量尺寸从 (3, 3, 4) 改成 (6, 6, 4),重跑。 全连接的参数从 370 涨到 1450,1x1 卷积还是 50。 这就是「参数与输入尺寸无关」的直接体现。
  2. 把输出维度从 10 改成 128,重跑。 全连接变成 36 乘 128 加 128 等于 4736,1x1 卷积变成 4 乘 128 加 128 等于 640。 差距从 7.4 倍拉大到 7.4 倍——比例不变,因为两者都是「输入通道乘输出通道」。
  3. 把摊平顺序改成「先通道、再行、再列」(A.transpose(2,0,1).reshape(-1)),重跑。 结果会完全不同。 顺序变了,同一个位置对应到全连接的不同权重上,模型就学乱了。

常见误区

  1. 「1x1 卷积是没用的卷积,等于什么都没做」。 它做的是通道之间的线性组合,能升维也能降维。 ResNet 的瓶颈结构、MobileNet 的逐点卷积,全靠它。
  2. 「摊平不损失信息,所以随便怎么摊」。 信息确实不损失,但「位置」这个含义依赖顺序。 训练和预测用了不同顺序,等于把权重对应到了错误的位置。
  3. 「全连接层太占参数,应该全部去掉」。 1x1 卷积不能混合不同位置的信息,某些任务确实需要全局连接。 该去的是「大尺寸输入接大隐层」那种配置,不是全连接本身。
  4. 「卷积网络的最后一定要接全连接」。 今天的分类网络多用全局平均池化加一层线性层,参数几乎为零。 全连接不是必须的,它是「想要全局连接」时的一种选择。
自测三问(点开对答案) 1. 为什么摊平的顺序必须固定? —— 因为摊平之后每个位置对应全连接层的一个权重。 训练时按一种顺序排,预测时按另一种排,同一个位置就落到不同的权重上,结果全乱。 信息没丢,但对应关系错了。 2. 1x1 卷积和全连接层是什么关系? —— 1x1 卷积就是「权重共享的全连接」:对每个像素位置,把通道维做一次线性组合。 两种写法的数值差只有 4.441e-16,是浮点舍入。 区别是全连接只做一次,1x1 卷积在每个位置都做一次、共用同一套权重。 3. 为什么说全连接层是参数重灾区? —— 因为参数量是输入维度乘输出维度再加偏置,是乘积关系。 36 到 10 是 370 个,36 到 128 是 4736 个,输入涨到 576 就是 5770 个。 所以卷积网络总要把尺寸压小再摊平。

卷积、池化、全连接都齐了,最后一层是输出层。 它要把一堆特征变成一个「属于哪一类」的答案,还要给训练提供可导的损失。 下一节看 softmax 和 sigmoid 各自适合什么场合。

相关笔记