读懂 CNN——卷积、特征图与池化
在之前的文章里,我们用全连接层在 MNIST 上训练了一个神经网络——一层里的每个神经元都连到下一层的每个神经元,权重通过反向传播更新。为了配合全连接层,我们不得不把每张 28×28 的图像摊平成 784 个元素的向量,再喂进一层 128 个神经元。 光是第一层就有 个权重 + 个偏置 = 个参数——每个输入像素都连到每个神经元。它确实能用——我们做到了 97% 的准确率——但这种做法有两个根本问题:
1. 没有空间意识。 全连接层把 784 个输入当成一个无序列表——像素 (5, 10) 和像素 (5, 11) 在图像里是邻居,但这一层的结构完全没有用到这个事实。摊平把所有空间结构都扔掉了——像素 (0, 0) 和像素 (27, 27) 只是向量里的两个数。如果数字往右移了几个像素,在全连接网络看来它就完全变了个样——同样的像素值现在落到了不同的输入下标上,激活的是另一批权重。网络只能把同一个模式在每一个可能的位置上分别学一遍。
2. 参数太多。 对 28×28 的图像来说,十万参数还能应付。但真实图像大得多。一张 224×224 的 RGB 图像(常见的输入尺寸)有 个像素 个颜色通道 个值。一层 512 个神经元的全连接层就需要 万个参数——而且只是一层。这训练起来很贵,也很容易过拟合。
为什么参数太多会导致过拟合?
参数越多,记忆的容量越大。有 7700 万个权重,却只有比方说 5 万张训练图像时,网络的参数远多于数据点。它可以不去学「圈圈意味着 0、直线意味着 1」这类一般规律,而基本上把每张训练图像逐字记下来——在训练集上拿到完美准确率,一碰到没见过的新图像就垮掉。
卷积神经网络(CNN) 一次解决了这两个问题。CNN 不是一口气看所有像素,而是聚焦在很小的局部区域上——比如一个 3×3 的小块——并学会识别其中的模式。根本区别在于:全连接层学的是一次性牵涉所有像素的全局模式,而卷积层学的是局部模式——并且在图像的每一个位置复用同一个检测器。
下面的图示演示了这一点。设想我们要在 28×28 的网格上区分正方形和圆形。为此我们需要检测局部差异——在正方形的角上你看到的是锐利的直角,而在圆的边缘你看到的是平缓的弧。这些局部片段正是 CNN 学着去检测的东西:
用 CNN 可以直接对上这两个问题:
- 空间意识:因为滤波器作用在一个局部的二维区域上,它天然就理解相邻像素是相关的。它是根据像素在空间上如何排布来检测边缘、弧线这类模式的——而不只是看它们在一个扁平列表里的位置。
- 参数少得多:一个 3×3 的滤波器只有 9 个参数(外加一个偏置),而且同一个滤波器在图像的每个位置都被复用。这叫权重共享。如果滤波器学会了检测一条直边,那么这条边出现在左边还是右边它都能检测到——全连接网络则要为每个位置准备各自的权重。
CNN 正是让现代计算机视觉成为可能的架构——它带来了图像搜索、手机相机场景识别、OCR 和视频分析上的突破,至今仍广泛用于自动驾驶、医学影像和机器人(尽管最前沿的系统越来越多地采用视觉 Transformer(ViT)或混合方案)。例如 PaddleOCR——最流行的开源 OCR 系统之一——就把 CNN 用作其文字检测与识别架构的核心部分。
除了图像,同样的想法也适用于一维数据——把滤波器滑过波形,用于语音识别(wav2vec 2.0)、音乐生成(WaveNet)和音频分类。就连 Liquid AI 的 LFM2 语言模型也把一维卷积当作核心构件——它 16 个块里有 10 个是用于局部 token 混合的短程卷积层。
神经网络中的卷积
要理解 CNN,你其实只需要抓住两个操作:卷积(把滤波器滑过图像以检测模式)和池化(把结果缩小,只留最强的信号)。这两者交替使用——卷积、池化——然后由我们熟悉的全连接层完成最终分类。 完整的模型在 Keras 里可能长这样:
model = keras.Sequential([
Input(...), # input image
Conv2D(...), # convolution
MaxPooling2D(...), # pooling
Conv2D(...), # convolution
MaxPooling2D(...), # pooling
Flatten(...), # flatten to 1D
Dense(...), # classification
])卷积检测模式,池化压缩结果,全连接层做最终分类。 下面就是完整的 CNN 流水线的样子——从像素到预测:
卷积层和池化层合起来就是特征提取器——它们把图像蒸馏成一个紧凑的表示,在那里正方形和圆形看起来截然不同。然后末尾的全连接层做简单的那部分:在那个特征空间里画一条决策边界。
卷积这一步检测局部模式——它把学到的滤波器滑过图像,产生特征图,标出每种模式出现在哪里。它不做任何分类,而是把原始像素网格变换成更丰富的表示。这就是特征提取,跟传统机器学习里的特征工程是同一个思路。与其把原始数据直接喂给分类器,不如先把它变换成更有用的表示。区别在于 CNN 会自动学会该提取哪些特征——卷积核的数值是在训练中被发现的,而不是手工设计的。
池化这一步在空间上压缩每张特征图——缩小宽和高,同时保留最强的信号。这减少了全连接层要处理的数值个数(在我们的模型里从 28×28×2 = 1,568 降到 14×14×2 = 392),也让特征对位置上的小幅移动更鲁棒。
读到本文结尾时,每一行都会讲得通,而且你会看到这个网络真正学到的滤波器数值。我们从卷积开始。
卷积作为图像变换
卷积不是机器学习的发明——它是信号处理与图像处理里的基础技术,远在深度学习之前就存在了。 如果你在图像编辑软件里用过模糊、锐化或边缘检测滤镜——那就是卷积。在底下,每个滤镜都是一小格数字(叫作卷积核),它在图像上滑动。 在每个位置上,卷积核的每个值都与其覆盖的像素相乘,乘积求和,结果写入输出。
试着在上面不同的滤镜之间切换。切换时留意两件事:中间的卷积核数值(那个 3×3 的数字网格),以及右边的输出如何变化。 不同的滤镜带来不同的变换:
注意水平边缘检测器如何突出了形状的上下边缘——这是因为卷积核上排是负值、下排是正值,所以它在亮度沿垂直方向变化的地方给出响应。锐化滤镜中心是一个大的正值、周围是负值——它放大了一个像素与其邻域的差异,让边缘更利落。
这些都是图像处理里经典且成熟的卷积核——在深度学习出现之前几十年就由人手工设计好了。 下面的说明解释每个核在算什么,以及输出为什么长成那样。
| 滤镜 | 它如何工作 |
|---|---|
| 模糊 | 把 9 个像素等权平均——把差异抹平。均匀区域保持不变,但尖锐的过渡被稀释 |
| 锐化 | 相对邻域放大中心像素。在平滑区域邻域互相抵消;在边缘处这种不匹配被夸大 |
| 水平边缘 | 用下方像素减去上方像素——如果它们相近,结果接近零。只有在亮度沿垂直方向变化处(也就是水平边缘)输出才大 |
| 垂直边缘 | 同样的思路旋转一下:用右边减左边。只有在亮度沿水平方向变化处(也就是垂直边缘)输出才强 |
同样的滑动操作、同样的输入——不同的结果。卷积核里那 9 个数字完全决定了输出的样子。 关于卷积如何工作的极佳视觉讲解,可以看 3Blue1Brown 的这个视频。
卷积作为模式匹配
还有另一种理解卷积的方式。与其用手工设计的核去检测边缘或做模糊,如果卷积核就是图像自身的一小块呢?我们可以取图像的一个小片段,然后问:还有哪里出现了类似的东西? 这就是模式匹配——卷积会在整幅图像上找出局部模式出现的位置。
点击下面形状的任意部位,把一块 3×3 的小片切出来当作卷积核。 比如点击正方形的竖直边,看它如何把所有竖直边都点亮;或者点击圆的弧线,看看哪里出现了类似的曲率。 这个组件会把核滑过整幅图像,凡是找到相似模式的地方,输出就会亮起来。 你也可以点 Play,一步步看滑动的机制:
试着点不同的部位——注意每一处都会给出不同的匹配分布。 这就是作为空间模式检测器的卷积。核定义了要找什么,输出告诉你它在哪儿被找到。 这正是 CNN 真正用到的性质——不是模糊或锐化,而是在整幅图像上检测已学到的模式的能力。 上一节的模糊和边缘滤镜有助于建立直觉,但在 CNN 里核的数值是自动学出来的,它们扮演的是模式检测器。CNN 的其余一切都建立在这一个想法之上。
从手工设计到学出来的滤波器
卷积的这两种用法——图像变换与模式匹配——都依赖于核值选得好。图像处理几十年来一直使用手工设计的核(上面演示里的模糊、锐化和 Sobel 滤镜)。它们很有用,但靠手挑既繁琐又受限。
CNN 的核心思想是:与其手工设计这些数值,不如让网络针对手头的任务自动学出它们。 滤波器从随机数开始,在训练中通过梯度下降被更新——和全连接层里学权重的方式一样。网络自己发现哪些模式对任务重要。对我们「正方形 vs 圆形」的任务来说,它可能学到对直边和弯边反应不同的滤波器——只要有助于把两类分开就行。
卷积运算
上面我们看到了卷积如何帮助做图像变换和模式匹配。现在来看真正的机制:在每个位置上究竟发生了什么,而那个被称为特征图的输出又是怎么一点点建起来的?
为什么叫「特征图」?
这里的「特征」跟机器学习里一般意义上的用法相同——一个对任务有用的可度量属性。就像表格模型可能用「年龄」或「收入」这类特征一样,CNN 的特征是「垂直边缘」或「弧线」这类视觉模式。特征图则是一张空间网格,显示这个特征在图像各处是否被检测到——有该模式的地方数值高,没有的地方数值低。
核心操作很直白:取一个小矩阵(卷积核),把它滑过输入,在每个位置计算点积——把每个核值乘以其覆盖的像素,把乘积求和——再把结果写进特征图。核可以是任意尺寸(1×1、5×5、7×7)——AlexNet 这类更早的网络用过 11×11 的核——但 3×3 是现代架构里最常见的选择,本文自始至终也用它。
来看实际的例子。假设你在上面的演示里点了正方形的左边缘——核就变成了一个 3×3 的小片,背景是黑(0),边线所在处是白(255)。当这个核滑到另一处左边缘时,图像小片长得一模一样——每个位置都对上了,所以乘积很大:
现在把同一个核用到上边缘。白像素的位置不同了——核里那一列白色大部分压在黑像素上:
在一片纯黑的区域上,每个像素都是 0,所以每个乘积都是 0——根本没有匹配。只有在核与图像小片吻合的地方,输出才强。把过程摊开来看:我们把 3×3 的核和 3×3 的图像区域都拉平成 9 元素向量,然后算它们的点积。两个向量越相似,结果越大——这就是为什么匹配的区域给出高值,不匹配的则不会。
同样的数学也解释了模糊。模糊核的每个值都是 ⅑,所以加权和就是这 9 个像素的平均。作用在左边缘的小片上:
在这一个位置上,输出像素约为 85——介于黑(0)与白(255)之间的一个灰值。核继续移动,一个位置一个位置、一行一行地处理图像。边缘附近的每个位置都会得到类似的混合值,把 0 到 255 的陡跳软化成渐变过渡。在平坦区域(全黑或全白)小片里每个像素都一样,所以平均值等于原值——那里什么都不会变。
输出尺寸
当滤波器不加填充地滑过输入时,输出会比输入小——这叫边界效应。考虑一个 5×5 的输入配 3×3 的核。核从左上角开始,覆盖第 0–2 行和第 0–2 列。它每次向右滑一个像素——但只能到第 0、1 或 2 列。如果从第 3 列开始,核就要覆盖第 3–5 列,而第 5 列并不存在。竖直方向同理。 所以每个轴上只有 3 个有效位置,5×5 的输入得到 3×3 的输出:
记住,在每个位置上,3×3 的小片(9 个值)都会通过加权和被压成一个输出值。这总是会发生。输出变小并不是因为这个压缩,而是因为有效位置比输入像素少——核根本不能从会挂到边界外的地方起步。一般公式是: 。对我们 28×28 的形状(以及同样大小的 MNIST 数字)来说:。
填充
为了抵消边界效应,你可以在输入四周加上若干行列的零——这叫填充(padding)。对 3×3 的核,每边加 1 个像素的零,于是 28×28 的输入变成 30×30 的填充输入,输出又回到 28×28。对 5×5 的核,每边就要加 2 个像素。
我们加的是零而不是比如说 1,因为零乘以任何核值都是零——填充的像素对点积没有任何贡献。只有与核重叠的真实像素才影响结果。这相当于在说「图像边界之外什么都没有」。
步幅
到目前为止我们都假设核每次移动一个像素——步幅(stride)为 1。 但相邻核位置之间的距离是一个你可以改的参数。 比如步幅为 2 时,核每次跳 2 个像素,隔一个位置跳一个——水平和垂直方向都是如此。 步幅越大,位置越少,输出也就越小。
还是前面那个 5×5 的输入,但现在步幅为 2。核只能放在 (0,0)、(0,2)、(2,0) 和 (2,2) 这几个位置——只有 4 个位置而不是 9 个,得到 2×2 的输出。宽和高各被下采样了 2 倍:
用步幅可以做到空间下采样——缩减特征图的宽和高。这很关键,因为它迫使网络把局部模式蒸馏成更紧凑的表示,减少后续层的参数量,并让更深的层在原图上拥有更宽的感受野。它做到这一点的方式,只是在卷积过程中跳过一些位置——学到的核身兼两职,既是模式检测器又是下采样器。
不过在分类网络里还有一种更常用的技术:最大池化(max-pooling)。它同样实现空间下采样,但走的是一个独立、无参数的操作——不是在卷积过程中跳过位置,而是在卷积做完之后用一条固定规则(取最大值)把特征图缩小。我们会在下面的池化一节里讲它。
卷积层内部
到目前为止我们都把卷积当成一个独立操作来看——一个核滑过图像,产生一张特征图。现在来看这个操作如何被打包成一个网络可以训练的层。在 Keras 里,卷积层是这样定义的:
layers.Conv2D(2, kernel_size=3, activation='relu', padding='same')尽管上面的组件里我们既展示了卷积核也展示了输出特征图,这一层实际存储的只有核的数值和偏置——并不存特征图。 特征图是在前向传播时即时算出来的:把存下来的核滑过当下进来的任何输入。特征图是输出,就像激活值是全连接层的输出一样——全连接层也只存权重矩阵,而不存激活值本身。
全连接层和卷积层都接收整个输入,但处理方式不同。拿一个简化的 3×3 网格和一个 2×2 的核来说。 一个全连接神经元对每个像素都有一个独立的权重(对我们这个简单的 3×3 网格是 9 个权重,对 28×28 的图像则是 784 个),算出一个加权和,产生一个值。
一个卷积核只有少数几个共享权重,但它会滑过图像的每一个位置,每个位置产生一个值——也就是一整张特征图。 在每个位置上,这一层从输入里取出一个局部小片,与核逐元素相乘,把所有乘积求和,再加上一个偏置。
全连接神经元用 9 个独立权重一次看完 9 个像素,产生一个输出。卷积核一次只看 4 个像素(2×2),但会走遍每一个位置——同样那 4 个权重,到处复用。参数更少,整幅图像却依然被覆盖到。
那张滑动示意图还有一种很有用的读法。核每访问一个位置就产生一个输出值——你可以把每个位置都看成一个自己的神经元。左上角的核是一个神经元,只看左上角那一小片;右移一步的核是另一个神经元,看的是平移了一个像素的小片;如此类推。对上面 3×3 的输入和 2×2 的核,核落在 4 个位置上——所以这个小小的层实际上就是 4 个神经元,它们的输出拼成 2×2 的特征图。放大来看,数量随图像增长:28×28 的输入配 3×3 的核,就成了一层 26×26 = 676 个神经元。
每个神经元所看的那一小片有个名字:它的感受野——它在输入中自己的那块关注区域。左上角神经元的感受野是左上角的 2×2,它只对那些像素有反应,完全无视图像的其余部分。每个神经元的感受野都不同,它们合起来铺满了整个输入。
正是在这里,与全连接层的反差最为鲜明。全连接神经元有自己的权重,看的是整个输入。卷积「神经元」在这两点上都相反:它只看自己那一小块感受野,而且——这最关键——它与该层其他所有神经元共享同一组权重,也就是那个核。上面那 4 个神经元并不是 4 个独立的检测器;它们是同一个检测器在 4 个地方的求值。这就是从神经元角度看到的权重共享:一个模式检测器,被复制到图像中每一个感受野上。
这也修好了我们开头提到的那个平移问题。因为同一个检测器坐落在每一个感受野之上,模式出现在哪里就在哪里被找到——左上角的一条边点亮左上角的神经元,而同一条边往下平移后落进另一个神经元的感受野,同样强烈地点亮它。特征无论位置如何都会被检测到。这个性质通常被称为平移不变性,尽管严格来说卷积层是等变的——平移输入,特征图就跟着一起平移;真正把小幅平移变成不变性的是后面的池化,在那里一个像素的抖动完全不改变输出。
而一旦层堆起来,神经元的感受野就会变大——第二层的神经元读取的是第一层输出的一小片,而其中每一个都已经概括了一小片像素,所以它间接看到了原图上更宽的区域。这就是我们在步幅和深度部分还会回来讲的「更宽的视野」。
下面是卷积层的从零实现,展示了完整的操作:
class Conv2D:
def __init__(self, num_filters, kernel_size, padding='same'):
self.kernels = np.random.randn(num_filters, kernel_size, kernel_size) * 0.1
self.biases = np.zeros(num_filters)
self.padding = (kernel_size - 1) // 2 if padding == 'same' else 0
def forward(self, input):
# Pad input with zeros if padding='same'
if self.padding > 0:
p = self.padding
input = np.pad(input, ((p, p), (p, p)), mode='constant')
self.input = input # save padded input for backward
h, w = input.shape
k = self.kernels.shape[1]
out_h, out_w = h - k + 1, w - k + 1
self.z = np.zeros((self.kernels.shape[0], out_h, out_w))
for f in range(len(self.kernels)): # each filter
for r in range(out_h): # each row
for c in range(out_w): # each column
patch = input[r:r+k, c:c+k] # extract local patch
self.z[f, r, c] = np.sum(patch * self.kernels[f]) + self.biases[f]
self.out = np.maximum(0, self.z) # ReLU activation
return self.out # shape: (num_filters, out_h, out_w)每个位置上的加权和,再加一个偏置项——整个操作就这些。偏置(每个核一个)把输出整体上移或下移,给这个核设定一个阈值:模式要匹配到多强它才激活。它的作用跟全连接层里的偏置一模一样。
卷积核是怎么学的:CNN 中的反向传播
核的数值一开始是随机的,然后通过反向传播更新,跟全连接网络里一样。关键区别在于权重共享。在全连接层里,每个权重连着一个特定输入,一次前向只用一次——所以每个权重的梯度来自单独一对 输入 × 误差。在卷积层里,同样那 9 个权重在图像的每个位置都被复用——所以反向传播时,每个位置都为同一批权重贡献梯度。下面的片段传达了这个意思:
# Dense layer: one weight, used once
dw = input * error # one gradient from one computation
# Conv layer: one weight, used at every position
dw[kr][kc] = sum over all (r, c):
input[r+kr][c+kc] * error[r][c] # same structure, summed across all positions这里的 error 是反向传播时由上面各层算出的一格格梯度值——它的具体数值取决于损失函数以及中间的各层,这些我们会在后续文章里讲。
但两种情况下结构是一样的:输入 × 误差。区别在于全连接的权重只看到一对这样的组合,而卷积的权重则把核被应用过的每一个位置的贡献都累加起来。
这个累加在我们的 Conv2D 类里长这样:
class Conv2D:
# ... __init__ and forward from above ...
def backward(self, upstream_gradient):
# ReLU backward: zero gradient where activation was ≤ 0
grad = upstream_gradient * (self.z > 0)
k = self.kernels.shape[1]
out_h, out_w = grad.shape[1], grad.shape[2]
self.grad_kernels = np.zeros_like(self.kernels)
for f in range(len(self.kernels)):
for kr in range(k):
for kc in range(k):
for r in range(out_h): # sum over every position
for c in range(out_w):
self.grad_kernels[f][kr][kc] += self.input[r+kr][c+kc] * grad[f][r][c]
def update(self, lr):
self.kernels -= lr * self.grad_kernels
self.biases -= lr * self.grad_biases把它和 forward 方法比一比:同样的嵌套循环,只不过不再计算输出值,而是在累加梯度。核被应用过的每一个位置都贡献给同一个梯度——这就是 9 个权重如何能从图像上成千上万个位置中学习。
多个卷积核,多张特征图
单个核只能检测一种模式。水平边缘检测器能找到水平边缘,却会漏掉竖直边缘、角和弧线。要抓住输入的多个方面,你需要多个核并行工作。下面是同一个正方形被两个不同的核处理的结果——每个都产生一张突出不同结构的特征图:
这就要求每层使用多个核,从而产生多张特征图——一核一图,各自检测不同的模式。 所以每个卷积层产生的特征图数量等于它拥有的核数——我们这里是 2,但这是刻意做到最小。
典型的网络在第一层会用 32 或 64 个核。每个核都是一格独立的 3×3(尺寸可配)学出来的数值,它们全都并行且独立地作用在同一个输入上:核 1 滑过整幅图像产生特征图 1,核 2 滑过产生特征图 2,如此类推。每个核学着检测不同的模式——正是这种并行让网络能同时抓住输入的多个方面,而不是一次只找一样东西。
下面是我们那个双滤波器 CNN 训练完之后的结果——它收敛到的核值,对正方形和圆形给出了明显不同的特征图。这些是我们单个卷积层的特征图;在更深的网络里,每一层都会产生自己的一组特征图,越深的层捕捉的模式越复杂:
每个滤波器为最终分类贡献不同的方面。滤波器 1(显示在左侧)像一个粗略的边缘检测器——对正方形,它沿每条边给出明暗分明的亮带和暗带;对圆形,则是沿圆周的一圈平滑渐变。滤波器 2(显示在右侧)对角和方向变化有反应——在正方形上它在四个角处点亮;在圆形上,它在曲率变化处给出交替的图案。
两个滤波器合起来,为每种形状造出了一个独特的签名:正方形的特征图在边和角上有尖锐、局部的激活,而圆形的则平滑并均匀地分布在圆环上。末尾的全连接层学会读这些签名——「尖角」意味着正方形,「平滑的环」意味着圆形。
令人惊讶的是,我们并没有告诉滤波器该检测什么——两个都从不同的随机值起步,是梯度下降把它们各自推向了最能降低损失的方向。 它们最终各有分工,是因为如果两个滤波器学到了同一个模式,其中一个就是冗余的,无法进一步帮助降低损失。 最小化损失的压力自然把滤波器推开,让每一个都捕捉输入的不同方面。
层层堆叠:从边缘到形状
我们「正方形 vs 圆形」的任务足够简单,一个卷积层就够了——2 个滤波器就能分开这两类。但对更难的任务,比如数字识别或人脸检测,你需要更多深度。
CNN 的真正威力来自把卷积层堆起来。要看清为什么,想想你自己会怎么手工分解一个识别问题。如果有人让你检测数字「0」,你可能会把它拆成子问题:有没有上方的弧?下方的弧?左边缘?右边缘? 每个子网络回答一个问题,最后一层把它们的输出综合起来:
这些子问题每一个都还能继续拆。「上方的弧?」可以拆成:有没有左上的弧?右上的弧?顶端的一段水平边?每个问题都更简单,也更接近原始的视觉特征:
这恰恰就是堆起来的卷积层会自动学会做的事——层次化特征提取。第一层找边缘,第二层把边缘组合成弧和角,更深的层再把这些组合成部件和形状。网络不需要被告知该找哪些特征——它从数据里学出整个层次结构。
而且在每一级上,都有多个核并行工作——一个核可能学着检测水平边缘,另一个检测竖直边缘;一个找直角,另一个找平滑的弧。 这就是为什么每层都有很多核——网络需要在层次结构的每个阶段同时追踪许多不同的模式。
多个输入通道
上面讲的一切描述的都是层次结构里的第一个卷积层,那里每个核接收的输入是单张二维图像。但堆栈里更深的层面对的东西不一样。第一个卷积层接收一张灰度图——单个 28×28 的二维网格。 但第二个卷积层的输入已经不是扁平的二维图像了——它是第一层的输出,是一摞二维特征图,因而是一个三维体(我们这里是 28×28×2,典型网络里可能是 28×28×32):
这些叠起来的层片通常被称为通道。每个通道是上一层某个核对输入的一种投射——比如通道 1 可能是输入的「边缘」视图,通道 2 是「角」视图。所以「通道」和「特征图」是同一样东西,只是视角不同:卷积层输出特征图,下一层把它们当作输入通道接收。之所以用「通道」这个词,是因为它不暗示任何次序或层级——它们是同一份数据的并列成分,各自描述同一空间区域的不同方面。
既然输入现在含有多张叠起来的特征图(多个通道),核就得把它们全都处理掉。 核会自动镜像输入的结构——它的深度(切片数)被推断为与输入通道数一致。 每层仍然可以——而且通常确实——有多个核,只是现在每个核变成了三维的:它的深度自动匹配输入通道数。
有 2 个输入通道时,每个核变成两张 2×2 矩阵叠起来的一摞,一张对应一个通道。在每个位置上,第一张矩阵与通道 1 相乘,第二张与通道 2 相乘,所有乘积汇总成每个位置上的一个输出值。 把这个三维核滑过所有位置,一个核的输出就只是一张特征图——每个位置一个值。
下面的图示展示了两个位置,用来说明同一个核如何产生不同的值,而它们都汇入那一张特征图:
这个操作还是我们已经熟悉的加权和——只不过每个通道各跑一次,然后把结果相加。图中是一个 2×2 的核滑过带 2 个通道的 3×3 输入。 在每个位置上,我们分别对每个通道算加权和,然后把结果加起来:
随着网络加深,每个核也变大——第一层的核是 3×3×1(9 个权重),而带 2 个输入通道的第二层核是 3×3×2(18 个权重)。通道数通常会随网络推进而增长(1 → 2 → 32 → 64),所以核也越来越深——而空间尺寸(3×3)通常保持不变。
多通道输入让更深的层有能力组合上一层的模式。 回想我们模型的特征图:滤波器 1 检测边缘,滤波器 2 检测角。 如果我们有第二个卷积层,它的核就会在每个位置同时看这两张特征图。 某个位置上滤波器 1 发现了竖直边、滤波器 2 发现了水平边——那就是一个角。 第二层的核学会组合这些信号:「这里有一条竖直边,同一处还有一条水平边——那一定是个角。」更深的层就是这样用简单特征搭出复杂特征的。
池化
在卷积层检测出特征之后,我们想把每张特征图缩小——有点像给图像降分辨率,只不过不是对像素取平均,而是只保留最强的信号。每张特征图都是独立缩小的——池化不会合并通道。深度保持不变:28×28×2 变成 14×14×2,而不是 14×14×1。
池化层从卷积层接过那一摞特征图,在空间上把每一张缩小,然后把同样数量的通道传给下一层。 不合并、不学习——只做空间压缩。 这有两个用处:数值更少意味着后续层的计算更少;同时它给了网络空间不变性——在像素 (10, 12) 检测到的特征和在 (11, 12) 检测到的特征,池化后都成了同一个值,使网络对小幅平移更鲁棒。
池化策略有好几种——最大、平均等等——但最常见的是最大池化:取一个 2×2 的窗口,以步幅 2 滑过特征图——由于步幅正好等于窗口大小,每个窗口覆盖一块全新的区域,互不重叠。 在每个位置上,只保留最大值。
池化听起来可能像带步幅的卷积——两者都滑动窗口并下采样。但它们有两点重要区别。第一,什么都不学——池化没有权重、没有核,没有任何会在训练中更新的参数。第二,操作本身不同——不是加权和(乘完再加),池化只是取最大值。
所以池化纯粹是一个变换——一条施加在数据上的固定规则,没有什么可学的。与卷积层和全连接层不同,池化层不存权重、不存偏置,完全没有参数。这就是为什么它们在模型的参数统计里显示为 0。你可以把它们看作夹在可训练层之间的一个「压缩步骤」,在保留重要信号的同时缩小空间尺寸。
在我们的模型里,池化把 28×28 的特征图降到 14×14:
最大池化保留每个区域里最强的激活。 如果一条边在某个 2×2 小片里的某处被检测到,取最大值就保住了这个信号——具体是那 4 个像素中的哪一个响应最强并不重要。这给了网络对小幅平移的容忍度:如果形状移动了一个像素,池化后同一块区域仍然捕捉到同一个特征。 代价是你丢失了精确的位置信息——池化之后你知道某个特征在那块 2×2 区域里的某处被检测到了,但不知道具体是哪个像素。
平均池化做的是平滑而不是锐化——它取窗口内所有值的均值。这产生更柔和的输出,但可能稀释强信号——一次强烈的边缘激活会被较弱的邻居平均掉,影响力被削减。最大池化在早期和中间层更常见,那里保住强响应更重要。平均池化有时出现在网络的最末端,作为全局平均池化,把整张特征图平均成一个数字——完全替代掉 flatten + 全连接那一步。
这是我们的 MaxPool2D 类——注意它没有 update 方法,因为没有参数可学:
class MaxPool2D:
def __init__(self, size=2):
self.size = size
def forward(self, x):
self.input = x # save for backward
s = self.size
channels, h, w = x.shape
out = np.zeros((channels, h // s, w // s))
for c in range(channels):
for r in range(0, h, s):
for col in range(0, w, s):
out[c, r//s, col//s] = np.max(x[c, r:r+s, col:col+s])
return out
def backward(self, gradient):
s = self.size
out = np.zeros_like(self.input)
channels, h, w = self.input.shape
for c in range(channels):
for r in range(0, h, s):
for col in range(0, w, s):
patch = self.input[c, r:r+s, col:col+s]
max_idx = np.unravel_index(np.argmax(patch), patch.shape)
out[c, r+max_idx[0], col+max_idx[1]] = gradient[c, r//s, col//s]
return out
# No update() — pooling has no parameters摊平并分类
注意,特征图在卷积和池化的整个过程中都是各自分开的——每一张独立缩小,从不合并。卷积层提取特征,池化层压缩它们,但到最后我们手上仍然是一个三维体(我们模型里是 14×14×2)。全连接层没法处理这个——它需要一个扁平的一维向量。
这正是 Flatten 层做的事:它把每个通道里的每个值都取出来,排成一个长向量——通道 1 的全部 14×14 = 196 个值,接着通道 2 的 196 个,合起来是一个 392 元素的向量。没有计算,也没有学习——只是改变形状。
这和我们在纯全连接的 MNIST 模型里把 28×28 摊成 784 时做的是同一件事。Flatten 本身既不知道也不在乎它在重排什么——操作是完全一样的。 区别在于它前面的层都做了什么:在纯全连接的做法里前面没有任何层,所以 Flatten 拿到的是原始像素。而这里,卷积和池化已经把空间模式提取并压缩过了,所以全连接层接到的是有意义的特征,而不是原始像素值。
接下来全连接层充当分类器。 因为我们只有两个类别(正方形 vs 圆形),这是二分类——只需要一个带 sigmoid 激活的神经元。它连到全部 392 个值,乘以学到的权重,求和,加上偏置,输出一个 0 到 1 之间的数:输入是圆形的概率。接近 0 = 正方形,接近 1 = 圆形。对 MNIST(10 个数字)这类多分类问题,你会改用 10 个神经元配 softmax——一类一个。
这里就是网络做出最终判断的地方。卷积层完成了苦活——把原始像素变成了「这里有边」「那里有角」这样有意义的特征。全连接层只是在那个 392 维的特征空间里画一条决策边界,把像正方形的模式和像圆形的模式分开。
这是 DenseLayer 类——结构与上一篇文章里的 HiddenLayer 相同:
class DenseLayer:
def __init__(self, n_inputs, n_outputs):
self.W = np.random.randn(n_outputs, n_inputs) * np.sqrt(2.0 / n_inputs)
self.b = np.zeros(n_outputs)
def forward(self, x):
self.x = x
return self.W @ x + self.b
def backward(self, grad_output):
self.grad_W = np.outer(grad_output, self.x)
self.grad_b = grad_output
return self.W.T @ grad_output
def update(self, lr):
self.W -= lr * self.grad_W
self.b -= lr * self.grad_b为形状搭一个 CNN 模型
既然每个部件都讲过了,我们来搭出模型和训练循环——就像当初为全连接的 MNIST 模型做的那样,只是现在换成卷积层。
我们把本文中造出来的这些层实例化:
# 28×28 grayscale → 2 feature maps → pooling → flatten → 1 output
conv = Conv2D(num_filters=2, kernel_size=3) # 20 parameters (2×9 weights + 2 biases)
pool = MaxPool2D(size=2) # 0 parameters
dense = DenseLayer(n_inputs=392, n_outputs=1) # 393 parameters (392 weights + 1 bias)准备数据
我们生成 4,000 个形状(2,000 个正方形 + 2,000 个圆形),大小和位置各不相同:
SIZE = 28
LINE_WIDTH = 2.0
rr, cc = np.mgrid[0:SIZE, 0:SIZE] + 0.5 # grid of pixel centers
def make_square():
img = np.zeros((SIZE, SIZE), dtype=np.float32)
s = np.random.randint(8, 22) # random side length
r = np.random.randint(0, SIZE - s + 1) # random position
c = np.random.randint(0, SIZE - s + 1)
for t in range(int(LINE_WIDTH)): # draw 2px thick edges
img[r+t, c:c+s] = 255; img[r+s-1-t, c:c+s] = 255
img[r:r+s, c+t] = 255; img[r:r+s, c+s-1-t] = 255
return img
def make_circle():
img = np.zeros((SIZE, SIZE), dtype=np.float32)
radius = np.random.uniform(4.0, 10.0)
margin = radius + 2.0
cy = np.random.uniform(margin, SIZE - margin)
cx = np.random.uniform(margin, SIZE - margin)
dist = np.sqrt((rr - cy)**2 + (cc - cx)**2) # vectorized distance
ring_dist = np.abs(dist - radius)
img[ring_dist <= LINE_WIDTH / 2] = 255 # solid ring
aa = (ring_dist > LINE_WIDTH / 2) & (ring_dist <= LINE_WIDTH / 2 + 0.5)
img[aa] = 255 * (LINE_WIDTH / 2 + 0.5 - ring_dist[aa]) / 0.5 # anti-aliasing
return img
N = 2000
squares = np.array([make_square() for _ in range(N)])
circles = np.array([make_circle() for _ in range(N)])为什么 np.mgrid 能让圆形生成变快
注意 make_circle 用的是 np.mgrid,而不是在全部 28×28 个像素上套一层 Python 嵌套循环。np.mgrid 一次性造出所有像素坐标的网格,然后 NumPy 用一次向量化调用就算出全部 784 个像素到圆心的距离——完全没有 Python 循环。对 2,000 个圆来说,这就是几秒钟和几分钟的差别。
然后归一化并切分:
X = np.concatenate([squares, circles]) / 255.0 # normalize to [0, 1]
y = np.concatenate([np.zeros(N), np.ones(N)]) # 0 = square, 1 = circle
X_train, X_test = X[:3200], X[3200:]
y_train, y_test = y[:3200], y[3200:]注意我们没有摊平图像——与那个把图像重排成 784 维向量的全连接 MNIST 模型不同,卷积层需要保持二维空间结构完好。
输出激活
我们的模型输出一个数——输入是圆形的概率。为了把全连接层的原始输出(可以是任意值)变成 0 到 1 之间的概率,我们使用 sigmoid:
def sigmoid(x):
return 1 / (1 + np.exp(-x))这跟逻辑回归里用的是同一个函数——它把任意值压进 (0, 1) 区间。对 MNIST 这类多分类问题,我们会改用 softmax。
损失函数
为了衡量预测错得有多离谱,我们使用二元交叉熵——跟 MNIST 那篇文章里的交叉熵损失是同一个思路,只是从十类改写成了两类:
def binary_cross_entropy(predicted, label):
# label is 0 (square) or 1 (circle)
# predicted is the sigmoid output (probability of circle)
return -label * np.log(predicted) - (1 - label) * np.log(1 - predicted)它衡量预测出的概率离真实标签有多远:如果模型对一个圆形说 0.95 而它确实是圆形,损失就很小;如果说的是 0.3,损失就很大。
训练循环
训练循环沿用前几篇文章里同样的四步流程——前向传播、损失、反向传播、梯度下降。前向传播把我们所有的层串起来:
def forward(image):
activated = conv.forward(image) # 28×28 → 28×28×2 (conv + ReLU)
pooled = pool.forward(activated) # 28×28×2 → 14×14×2
flat = pooled.reshape(-1) # 14×14×2 → 392
output = sigmoid(dense.forward(flat)) # 392 → 1
return output反向传播反着来镜像它——从损失的梯度往回穿过全连接层、反摊平、池化和卷积。至于为什么 output - label 是 sigmoid + 二元交叉熵这一组合的正确起始梯度,我们会在后面的文章里深入讲:
def backward(output, label):
grad = output - label # sigmoid + BCE gradient
grad = dense.backward(grad) # dense layer
grad = grad.reshape(14, 14, 2) # un-flatten
grad = pool.backward(grad) # pooling: route to max positions
conv.backward(grad) # conv: ReLU + accumulate across positions前向和反向都定义好之后,训练循环就很直白了:
def train(X_train, y_train, epochs=20, lr=0.25, batch_size=32):
for epoch in range(epochs):
indices = np.random.permutation(len(X_train))
for i in range(0, len(X_train), batch_size):
batch_idx = indices[i:i+batch_size]
bs = len(batch_idx)
# Accumulate gradients over the mini-batch
acc_conv_k = np.zeros_like(conv.kernels)
acc_conv_b = np.zeros_like(conv.biases)
acc_dense_W = np.zeros_like(dense.W)
acc_dense_b = np.zeros_like(dense.b)
for idx in batch_idx:
# 1. Forward pass
output = forward(X_train[idx])
# 2. Loss
loss = binary_cross_entropy(output, y_train[idx])
# 3. Backpropagation (computes per-sample gradients)
backward(output, y_train[idx])
# Accumulate
acc_conv_k += conv.grad_kernels
acc_conv_b += conv.grad_biases
acc_dense_W += dense.grad_W
acc_dense_b += dense.grad_b
# 4. Average gradients and update weights
conv.grad_kernels = acc_conv_k / bs
conv.grad_biases = acc_conv_b / bs
dense.grad_W = acc_dense_W / bs
dense.grad_b = acc_dense_b / bs
conv.update(lr)
dense.update(lr)训练结果
我们在 4,000 个生成出来的形状(2,000 个正方形 + 2,000 个圆形)上训练。下面是 20 个 epoch 的训练进展——训练集和验证集上损失都在下降,准确率都在上升:
模型达到 100% 的测试准确率——而它学到的滤波器,正是你在上面特征图演示里看到的那些。
作为对照,这里是等价的 Keras 写法——上面整个模型加训练循环坍缩成了寥寥几行:
model = keras.Sequential([
layers.Input(shape=(28, 28, 1)),
layers.Conv2D(2, kernel_size=3, activation='relu', padding='same'),
layers.MaxPooling2D(pool_size=2),
layers.Flatten(),
layers.Dense(1, activation='sigmoid'),
])
model.compile(optimizer='adam', loss='binary_crossentropy', metrics=['accuracy'])
model.fit(X_train, y_train, epochs=20, batch_size=32, validation_split=0.1)放大规模:从形状到 MNIST
我们的形状分类器只有 413 个参数,因为任务很简单。真正的图像分类需要更大的容量。下面是一个用于 MNIST 数字识别的典型 CNN——原理相同,只是更大:
model = keras.Sequential([
layers.Input(shape=(28, 28, 1)),
layers.Conv2D(32, kernel_size=3, activation='relu'), # 28×28×1 → 26×26×32
layers.MaxPooling2D(pool_size=2), # 26×26×32 → 13×13×32
layers.Conv2D(64, kernel_size=3, activation='relu'), # 13×13×32 → 11×11×64
layers.MaxPooling2D(pool_size=2), # 11×11×64 → 5×5×64
layers.Flatten(), # 5×5×64 → 1600
layers.Dense(128, activation='relu'), # 1600 → 128
layers.Dense(10, activation='softmax'), # 128 → 10
])与我们那个玩具模型的差别:
- 28×28 的输入尺寸相同——但换成了灰度手写数字,而不是简单的几何形状
- 32 和 64 个滤波器而不是 2 个——要检测的模式多得多
- 两个卷积+池化块而不是一个——层次化的特征提取
- 10 类 softmax 而不是二元 sigmoid——要区分 10 个数字
- 约 225,000 个参数而不是 359——容量大得多
但构件是完全一样的:滑动小滤波器来构建特征图、池化来缩小尺寸、摊平、分类。下面是 5 个 epoch 的训练进展:
这个 CNN 在 MNIST 上 5 个 epoch 就达到 约 99% 的测试准确率——相比只用全连接层拿到的约 97%,是明显的提升。