读懂 CNN——卷积、特征图与池化
在之前的文章里,我们用全连接层在 MNIST 上训练了一个神经网络——一层里的每个神经元都连到下一层的每个神经元,权重通过反向传播更新。为了配合全连接层,我们不得不把每张 28×28 的图像摊平成 784 个元素的向量,再喂进一层 128 个神经元。 光是第一层就有 个权重 + 个偏置 = 个参数——每个输入像素都连到每个神经元。它确实能用——我们做到了 97% 的准确率——但这种做法有两个根本问题:
1. 没有内置的空间结构。 展平会保留每个像素及其顺序,可以将向量重新还原为图像。但全连接层并未内置像素邻接关系,也不会在不同位置共享同一个检测器。图案移动后会作用于不同的权重,因此在一个位置学会识别,并不自动意味着在其他位置也能识别。
2. 参数太多。 对 28×28 的图像来说,十万参数还能应付。但真实图像大得多。一张 224×224 的 RGB 图像(常见的输入尺寸)有 个像素 个颜色通道 个值。一层 512 个神经元的全连接层就需要 万个参数——而且只是一层。这训练起来很贵,也很容易过拟合。
参数量如何影响过拟合?
卷积神经网络(CNN) 一次解决了这两个问题。CNN 不是一口气看所有像素,而是聚焦在很小的局部区域上——比如一个 3×3 的小块——并学会识别其中的模式。根本区别在于:全连接层学的是一次性牵涉所有像素的全局模式,而卷积层学的是局部模式——并且在图像的每一个位置复用同一个检测器。
考虑在 28×28 网格上区分正方形和圆形。局部图像块包含有用线索:正方形有尖锐的角和直边,圆形有弯曲的边缘。图中展示的是候选特征,模型实际使用哪些响应则由训练决定。
用 CNN 可以直接对上这两个问题:
卷积提供了局部连接和权重共享。对于单个输入通道,3×3 滤波器有 9 个权重和一个偏置。同一组权重用于所有位置,因此该层可以在不同位置响应学到的图案。
CNN 用于图像分类、目标检测和文字识别。同样的滑动滤波操作也可以扩展到音频等一维信号,以及体积图像等三维输入。
神经网络中的卷积
我们的 CNN 由卷积、ReLU 激活、池化和全连接分类器组成。卷积计算局部响应,ReLU 引入非线性,池化缩小空间尺寸。下面用 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),也让特征对位置上的小幅移动更鲁棒。
我们将用 NumPy 实现这些层,并观察它们在简单的形状分类任务中学到的滤波器。
卷积作为图像变换
卷积不是机器学习的发明——它是信号处理与图像处理里的基础技术,远在深度学习之前就存在了。 如果你在图像编辑软件里用过模糊、锐化或边缘检测滤镜——那就是卷积。在底下,每个滤镜都是一小格数字(叫作卷积核),它在图像上滑动。 在每个位置上,卷积核的每个值都与其覆盖的像素相乘,乘积求和,结果写入输出。
切换下方的滤波器,将 3×3 卷积核中的数值与右侧输出进行比较:
注意水平边缘检测器如何突出了形状的上下边缘——这是因为卷积核上排是负值、下排是正值,所以它在亮度沿垂直方向变化的地方给出响应。锐化滤镜中心是一个大的正值、周围是负值——它放大了一个像素与其邻域的差异,让边缘更利落。
这些都是图像处理里经典且成熟的卷积核——在深度学习出现之前几十年就由人手工设计好了。 下面的说明解释每个核在算什么,以及输出为什么长成那样。
| 滤镜 | 它如何工作 |
|---|---|
| 模糊 | 把 9 个像素等权平均——把差异抹平。均匀区域保持不变,但尖锐的过渡被稀释 |
| 锐化 | 相对邻域放大中心像素。在平滑区域邻域互相抵消;在边缘处这种不匹配被夸大 |
| 水平边缘 | 用下方像素减去上方像素——如果它们相近,结果接近零。只有在亮度沿垂直方向变化处(也就是水平边缘)输出才大 |
| 垂直边缘 | 同样的思路旋转一下:用右边减左边。只有在亮度沿水平方向变化处(也就是垂直边缘)输出才强 |
同样的滑动操作、同样的输入——不同的结果。卷积核里那 9 个数字完全决定了输出的样子。 关于卷积如何工作的极佳视觉讲解,可以看 3Blue1Brown 的这个视频。
卷积作为模式匹配
滑动窗口也可以用于图案匹配。选取一小块图像,与其他位置的图像块进行比较。下一个演示会对比较结果进行归一化,以突出图案本身,而不是整体亮度。
点击下面形状的任意部位,把一块 3×3 的小片切出来当作卷积核。 比如点击正方形的竖直边,看它如何把所有竖直边都点亮;或者点击圆的弧线,看看哪里出现了类似的曲率。 这个组件会把核滑过整幅图像,凡是找到相似模式的地方,输出就会亮起来。 你也可以点 Play,一步步看滑动的机制:
此演示使用余弦相似度:将点积除以卷积核向量和图像块向量的长度。任一向量为零时,得分定义为零;显示时将低于 0.85 的得分压暗。CNN 卷积不会进行这种归一化,而是计算加权和并加上偏置。这个演示说明了滑动图像块进行搜索的过程,但显示的得分并不是卷积输出。
从手工设计到学出来的滤波器
卷积的这两种用法——图像变换与模式匹配——都依赖于核值选得好。图像处理几十年来一直使用手工设计的核(上面演示里的模糊、锐化和 Sobel 滤镜)。它们很有用,但靠手挑既繁琐又受限。
CNN 的核心思想是:与其手工设计这些数值,不如让网络针对手头的任务自动学出它们。 滤波器从随机数开始,在训练中通过梯度下降被更新——和全连接层里学权重的方式一样。网络自己发现哪些模式对任务重要。对我们「正方形 vs 圆形」的任务来说,它可能学到对直边和弯边反应不同的滤波器——只要有助于把两类分开就行。
卷积运算
上面我们看到了卷积如何帮助做图像变换和模式匹配。现在来看真正的机制:在每个位置上究竟发生了什么,而那个被称为特征图的输出又是怎么一点点建起来的?
为什么叫「特征图」?
这里的「特征」跟机器学习里一般意义上的用法相同——一个对任务有用的可度量属性。就像表格模型可能用「年龄」或「收入」这类特征一样,CNN 的特征是「垂直边缘」或「弧线」这类视觉模式。特征图则是一张空间网格,显示这个特征在图像各处是否被检测到——有该模式的地方数值高,没有的地方数值低。
核心操作很直白:取一个小矩阵(卷积核),把它滑过输入,在每个位置计算点积——把每个核值乘以其覆盖的像素,把乘积求和——再把结果写进特征图。核可以是任意尺寸(1×1、5×5、7×7)——AlexNet 这类更早的网络用过 11×11 的核——但 3×3 是现代架构里最常见的选择,本文自始至终也用它。
来看实际的例子。假设你在上面的演示里点了正方形的左边缘——核就变成了一个 3×3 的小片,背景是黑(0),边线所在处是白(255)。当这个核滑到另一处左边缘时,图像小片长得一模一样——每个位置都对上了,所以乘积很大:
现在把同一个核用到上边缘。白像素的位置不同了——核里那一列白色大部分压在黑像素上:
较大的点积可能来自数值的对应关系、较大的数值幅度,或两者共同作用。它不是归一化的相似度指标:这里全白图像块的点积与匹配边缘相同,因为零权重下的像素不影响求和。学习得到的滤波器还可以用负权重抑制不需要的结构。
同样的数学也解释了模糊。模糊核的每个值都是 ⅑,所以加权和就是这 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 的输出:
对于单个维度,设输入大小为 、卷积核大小为 、每侧填充为 、步幅为 ,且不使用空洞卷积,则输出大小为 。这里 、,所以得到 。
填充
为了抵消边界效应,你可以在输入四周加上若干行列的零——这叫填充(padding)。对 3×3 的核,每边加 1 个像素的零,于是 28×28 的输入变成 30×30 的填充输入,输出又回到 28×28。对 5×5 的核,每边就要加 2 个像素。
我们加的是零而不是比如说 1,因为零乘以任何核值都是零——填充的像素对点积没有任何贡献。只有与核重叠的真实像素才影响结果。这相当于在说「图像边界之外什么都没有」。
步幅
到目前为止我们都假设核每次移动一个像素——步幅(stride)为 1。 但相邻核位置之间的距离是一个你可以改的参数。 比如步幅为 2 时,核每次跳 2 个像素,隔一个位置跳一个——水平和垂直方向都是如此。 步幅越大,位置越少,输出也就越小。
步幅为 2 时,卷积核每次移动两个像素。对于这里的 5×5 输入和 3×3 卷积核,在无填充的情况下,输出从 3×3 变为 2×2。
下采样减少激活值数量和后续层的计算量,也会减少展平之后的全连接层参数量。对于卷积核大小和通道数固定的后续卷积层,参数量保持不变。
我们的模型通过最大池化下采样,这个独立操作会选取每个局部窗口中的最大值。下面会实现它。
卷积层内部
到目前为止我们都把卷积当成一个独立操作来看——一个核滑过图像,产生一张特征图。现在来看这个操作如何被打包成一个网络可以训练的层。在 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,它只对那些像素有反应,完全无视图像的其余部分。每个神经元的感受野都不同,它们合起来铺满了整个输入。
全连接神经元对整个输入拥有自己的一组权重。卷积输出使用局部感受野,并与同一输出通道的其他位置共享权重。上面的四个位置是同一滤波器在四个地点的计算结果;其他输出通道使用各自的滤波器。
忽略边界效应时,权重共享使步幅为 1 的卷积具有平移等变性:输入平移,特征图也随之平移。这并不保证最终分类器具有平移不变性。池化可以降低对某些小幅移动的敏感度,但跨越池化窗口边界仍可能改变结果。
而一旦层堆起来,神经元的感受野就会变大——第二层的神经元读取的是第一层输出的一小片,而其中每一个都已经概括了一小片像素,所以它间接看到了原图上更宽的区域。这就是我们在步幅和深度部分还会回来讲的「更宽的视野」。
下面的 NumPy 实现适用于单输入通道、步幅 1 和奇数大小的卷积核。输出采用 (channels, height, width) 顺序,而 Keras 默认使用 (height, width, channels)。首先导入 import numpy as np:
import numpy as np
class Conv2D:
def __init__(self, num_filters, kernel_size, padding='same'):
if padding not in ('same', 'valid') or kernel_size % 2 == 0:
raise ValueError("Use an odd kernel size and 'same' or 'valid' padding")
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 中的反向传播
反向传播计算梯度,优化器利用梯度更新权重。对于一个样本,全连接层的一个权重参与一次输出计算;卷积权重在所有空间位置重复使用,因此其梯度需要对这些位置的贡献求和。两种层还都需要在小批量的样本之间累积梯度。
# For one sample and one dense weight:
dw = input_value * output_gradient
# For one convolution weight (kr, kc), sum over output positions:
dw = sum(
padded_input[r + kr, c + kc] * gradient[r, c]
for r in range(out_h)
for c in range(out_w)
)这里的 gradient[r, c] 是计入 ReLU 导数后,损失对该输出位置激活前数值的导数。每一项都是输入值乘以对应的输出梯度。
这个累加在我们的 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_biases = grad.sum(axis=(1, 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偏置梯度是 grad 在空间位置上的总和。这个首层实现只计算参数梯度;若要堆叠多个可训练卷积层,还需要返回相对于输入的梯度。
多个卷积核,多张特征图
一个滤波器生成一张特征图,记录其权重在各位置产生的响应。不同滤波器可以响应不同结构,但单个滤波器不一定对应某种容易命名的图案。
这就要求每层使用多个核,从而产生多张特征图——一核一图,各自检测不同的模式。 所以每个卷积层产生的特征图数量等于它拥有的核数——我们这里是 2,但这是刻意做到最小。
增加滤波器会增加输出通道数和模型容量。训练可能产生互补的滤波器,也可能产生冗余或不活跃的滤波器。
下面是我们那个双滤波器 CNN 训练完之后的结果——它收敛到的核值,对正方形和圆形给出了明显不同的特征图。这些是我们单个卷积层的特征图;在更深的网络里,每一层都会产生自己的一组特征图,越深的层捕捉的模式越复杂:
这些特征图使用下方训练得到的权重和偏置,输入像素归一化到 [0, 1],并应用 ReLU。每张图的亮度都独立缩放,因此应比较空间分布,而不是跨图比较绝对亮度。
全连接层接收两张特征图经过池化后的响应。这些响应可以帮助区分两类形状,而无需让某个滤波器专门成为“正方形检测器”或“圆形检测器”。
层层堆叠:从边缘到形状
对于这个生成的数据集,一个带有两个滤波器的卷积层就足够了。堆叠多个层可以让后续滤波器在更大的感受野内组合先前的响应,从而帮助处理更多样的图像。
CNN 的真正威力来自把卷积层堆起来。要看清为什么,想想你自己会怎么手工分解一个识别问题。如果有人让你检测数字「0」,你可能会把它拆成子问题:有没有上方的弧?下方的弧?左边缘?右边缘? 每个子网络回答一个问题,最后一层把它们的输出综合起来:
这些子问题每一个都还能继续拆。「上方的弧?」可以拆成:有没有左上的弧?右上的弧?顶端的一段水平边?每个问题都更简单,也更接近原始的视觉特征:
“边缘 → 曲线 → 部件”有助于说明感受野的扩大,但不能保证每一层都会学到这些特征。实际特征取决于数据、架构和训练过程。
而且在每一级上,都有多个核并行工作——一个核可能学着检测水平边缘,另一个检测竖直边缘;一个找直角,另一个找平滑的弧。 这就是为什么每层都有很多核——网络需要在层次结构的每个阶段同时追踪许多不同的模式。
多个输入通道
上面讲的一切描述的都是层次结构里的第一个卷积层,那里每个核接收的输入是单张二维图像。但堆栈里更深的层面对的东西不一样。第一个卷积层接收一张灰度图——单个 28×28 的二维网格。 但第二个卷积层的输入已经不是扁平的二维图像了——它是第一层的输出,是一摞二维特征图,因而是一个三维体(我们这里是 28×28×2,典型网络里可能是 28×28×32):
每张输出特征图都会成为下一层的输入通道。这些通道是在相同空间位置上的并行分量。通道顺序必须与下一层权重保持一致;只交换通道而不交换对应权重会改变计算结果。
既然输入现在含有多张叠起来的特征图(多个通道),核就得把它们全都处理掉。 核会自动镜像输入的结构——它的深度(切片数)被推断为与输入通道数一致。 每层仍然可以——而且通常确实——有多个核,只是现在每个核变成了三维的:它的深度自动匹配输入通道数。
有 2 个输入通道时,每个核变成两张 2×2 矩阵叠起来的一摞,一张对应一个通道。在每个位置上,第一张矩阵与通道 1 相乘,第二张与通道 2 相乘,所有乘积汇总成每个位置上的一个输出值。 把这个三维核滑过所有位置,一个核的输出就只是一张特征图——每个位置一个值。
下面的图示展示了两个位置,用来说明同一个核如何产生不同的值,而它们都汇入那一张特征图:
这个操作还是我们已经熟悉的加权和——只不过每个通道各跑一次,然后把结果相加。图中是一个 2×2 的核滑过带 2 个通道的 3×3 输入。 在每个位置上,我们分别对每个通道算加权和,然后把结果加起来:
对于普通卷积,每个输出滤波器都会覆盖所有输入通道。其参数量为 kernel_height × kernel_width × input_channels + 1,其中 1 对应偏置。网络加深本身不会让卷积核变大,输入通道数的改变才会影响这一数量。
池化
在卷积层检测出特征之后,我们想把每张特征图缩小——有点像给图像降分辨率,只不过不是对像素取平均,而是只保留最强的信号。每张特征图都是独立缩小的——池化不会合并通道。深度保持不变:28×28×2 变成 14×14×2,而不是 14×14×1。
池化没有可学习权重,而是在每个通道内独立汇总局部窗口。最大池化保留最大值,可以容忍窗口内的某些移动,但一般不具有平移不变性。
池化策略有好几种——最大、平均等等——但最常见的是最大池化:取一个 2×2 的窗口,以步幅 2 滑过特征图——由于步幅正好等于窗口大小,每个窗口覆盖一块全新的区域,互不重叠。 在每个位置上,只保留最大值。
池化听起来可能像带步幅的卷积——两者都滑动窗口并下采样。但它们有两点重要区别。第一,什么都不学——池化没有权重、没有核,没有任何会在训练中更新的参数。第二,操作本身不同——不是加权和(乘完再加),池化只是取最大值。
在我们的模型里,池化把 28×28 的特征图降到 14×14:
如果响应在同一个池化窗口内移动,并且仍然是最大值,该窗口的池化结果就不会改变。但一旦跨越窗口边界,输出便可能变化。池化只提供有限的平移容忍度,并不保证移动一个像素后预测保持不变。
平均池化取每个窗口的平均值,而不是最大值。全局平均池化将整张特征图汇总为每通道一个数值。分类器随后可以使用这些数值,通常再接一个全连接层,而不必展平所有空间位置。
我们的 MaxPool2D 使用不重叠窗口,丢弃末尾无法组成完整窗口的行或列。反向传播时,每个梯度传给窗口内的最大值;若有并列最大值,则选择第一个。该层没有需要更新的参数:
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 + 1, s):
for col in range(0, w - s + 1, s):
out[c, r//s, col//s] = np.max(x[c, r:r+s, col:col+s])
self.output_shape = out.shape
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 + 1, s):
for col in range(0, w - s + 1, 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摊平并分类
池化独立处理各个通道,卷积则可以将通道信息组合起来。对于这个分类器,我们将所有池化后的激活展平成一个向量,使全连接输出连接到每个通道的每个空间位置。Keras 的 Dense 可以接收更高维的输入,但此时只沿最后一个轴运算,不会自动展平输入。
我们的 NumPy 数组采用通道在前的布局:展平时先读取通道 1 的 196 个值,再读取通道 2 的 196 个值。Keras 默认将通道放在最后,因此展平顺序不同。只要前向和反向传播使用一致的布局,两种约定都可以。
这和我们在纯全连接的 MNIST 模型里把 28×28 摊成 784 时做的是同一件事。Flatten 本身既不知道也不在乎它在重排什么——操作是完全一样的。 区别在于它前面的层都做了什么:在纯全连接的做法里前面没有任何层,所以 Flatten 拿到的是原始像素。而这里,卷积和池化已经把空间模式提取并压缩过了,所以全连接层接到的是有意义的特征,而不是原始像素值。
接下来全连接层充当分类器。 因为我们只有两个类别(正方形 vs 圆形),这是二分类——只需要一个带 sigmoid 激活的神经元。它连到全部 392 个值,乘以学到的权重,求和,加上偏置,输出一个 0 到 1 之间的数:输入是圆形的概率。接近 0 = 正方形,接近 1 = 圆形。对 MNIST(10 个数字)这类多分类问题,你会改用 10 个神经元配 softmax——一类一个。
这是 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 个圆形。两类使用相同的尺寸范围、边界留白、线宽和边缘平滑规则,以减少模型利用绘制差异取巧的机会。这仍然是一个刻意简化的合成任务。
np.random.seed(42)
SIZE = 28
LINE_WIDTH = 2.0
rr, cc = np.mgrid[0:SIZE, 0:SIZE] + 0.5 # grid of pixel centers
def make_shape(kind):
radius = np.random.uniform(4.0, 10.0)
margin = radius + 2.0
cy, cx = np.random.uniform(margin, SIZE - margin, size=2)
dy, dx = np.abs(rr - cy), np.abs(cc - cx)
distance = np.maximum(dy, dx) if kind == 'square' else np.hypot(dy, dx)
edge_distance = np.abs(distance - radius)
coverage = np.clip((LINE_WIDTH / 2 + 0.5 - edge_distance) / 0.5, 0, 1)
return (255 * coverage).astype(np.float32)
N = 2000
squares = np.array([make_shape('square') for _ in range(N)])
circles = np.array([make_shape('circle') for _ in range(N)])为什么使用 np.mgrid?
np.mgrid 创建像素坐标数组。随后 make_shape 使用 NumPy 数组运算计算所有像素的距离和边缘覆盖率,无需用 Python 循环逐个处理像素。先将像素归一化到 [0, 1],再分别划分每个类别:1,440 张用于训练,160 张用于验证,400 张用于测试。在各子集中打乱顺序后,得到类别均衡的 2,880 张训练图像、320 张验证图像和 800 张测试图像。测试集保留到训练完成后再使用。
X = np.concatenate([squares, circles]) / 255.0 # normalize to [0, 1]
y = np.concatenate([np.zeros(N), np.ones(N)]) # 0 = square, 1 = circle
# Split each class separately, then shuffle each subset.
splits = [[], [], []]
for label in (0, 1):
class_indices = np.random.permutation(np.flatnonzero(y == label))
for target, part in zip(splits, np.split(class_indices, [1440, 1600])):
target.extend(part)
train_idx, val_idx, test_idx = [np.random.permutation(part) for part in splits]
X_train, y_train = X[train_idx], y[train_idx]
X_val, y_val = X[val_idx], y[val_idx]
X_test, y_test = X[test_idx], y[test_idx]注意我们没有摊平图像——与那个把图像重排成 784 维向量的全连接 MNIST 模型不同,卷积层需要保持二维空间结构完好。
输出激活
我们的模型输出一个数——输入是圆形的概率。为了把全连接层的原始输出(可以是任意值)变成 0 到 1 之间的概率,我们使用 sigmoid:
def sigmoid(x):
z = np.exp(-np.abs(x))
return np.where(np.asarray(x) >= 0, 1 / (1 + z), z / (1 + z))这跟逻辑回归里用的是同一个函数——它把任意值压进 (0, 1) 区间。对 MNIST 这类多分类问题,我们会改用 softmax。
损失函数
为了衡量预测错得有多离谱,我们使用二元交叉熵——跟 MNIST 那篇文章里的交叉熵损失是同一个思路,只是从十类改写成了两类:
def binary_cross_entropy(predicted, label):
# Clip probabilities to keep log(0) out of the reported loss.
p = np.clip(predicted, 1e-10, 1 - 1e-10)
return -label * np.log(p) - (1 - label) * np.log(1 - p)对于圆形,预测 0.95 的损失小于预测 0.3。裁剪概率可让这个用于报告的损失函数在概率为 0 或 1 时仍返回有限值。下方反向传播使用裁剪前 sigmoid 与 BCE 组合的解析梯度 p - y;生产实现通常直接从 logits 计算 BCE,以提高数值稳定性。
训练循环
与笔记本保持一致,在生成并划分数据后重新创建模型。整个前向传播都使用通道在前的数组布局:
conv = Conv2D(num_filters=2, kernel_size=3)
pool = MaxPool2D(size=2)
dense = DenseLayer(n_inputs=392, n_outputs=1)
def forward(image):
activated = conv.forward(image) # (28, 28) → (2, 28, 28)
pooled = pool.forward(activated) # (2, 28, 28) → (2, 14, 14)
flat = pooled.reshape(-1) # (2, 14, 14) → (392,)
return sigmoid(dense.forward(flat))[0]反向传播反着来镜像它——从损失的梯度往回穿过全连接层、反摊平、池化和卷积。至于为什么 output - label 是 sigmoid + 二元交叉熵这一组合的正确起始梯度,我们会在后面的文章里深入讲:
def backward(output, label):
grad = np.array([output - label]) # sigmoid + BCE gradient
grad = dense.backward(grad) # dense layer
grad = grad.reshape(pool.output_shape) # un-flatten
grad = pool.backward(grad) # pooling: route to max positions
conv.backward(grad) # conv: ReLU + accumulate across positions我们累积每张图像的梯度,在每次 SGD 更新前取平均。笔记本还会在每个 epoch 后计算验证损失,并在训练结束时评估一次测试集。其中向量化的卷积和池化与这里的循环执行相同的运算。
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)训练结果
下面的训练使用 2,880 张训练图像和 320 张验证图像,共进行 20 个 epoch。训练指标在每个 epoch 内逐步累积,验证指标使用该 epoch 结束时的权重计算。独立的 800 张测试图像不参与权重更新,也不用于验证曲线。
图表下方显示最终的独立测试集准确率。可下载笔记本会导出这些指标以及特征图演示使用的权重。在这些生成形状上的表现并不能证明模型对其他绘制风格或真实图像同样稳健。
同一架构在 Keras 中使用通道在后的输入。这个版本使用 Adam,而笔记本使用 SGD,因此训练过程会有所不同:
from tensorflow import keras
from tensorflow.keras import layers
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[..., None], y_train, epochs=20, batch_size=32,
validation_data=(X_val[..., None], y_val))放大规模:从形状到 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 个参数而不是 413——容量大得多
但构件是完全一样的:滑动小滤波器来构建特征图、池化来缩小尺寸、摊平、分类。下面是 5 个 epoch 的训练进展:
保存的 MNIST 训练在 5 个 epoch 后达到约 99.08% 的验证准确率。图中的数值是验证指标,并未记录独立的最终测试结果。若要与之前的全连接网络比较,需要在同一个独立测试集上评估两种模型。