一张 224×224224\times224 的 RGB 图像包含 150,528 个数。若直接把这些数交给全连接层,模型既要维护巨大的权重矩阵,也看不到像素原本的二维邻接关系。卷积神经网络(Convolutional Neural Network,CNN)用局部连接和权重共享处理这两个问题:同一个小型卷积核在整张图上滑动,寻找可重复出现的局部模式,再把简单模式逐层组合成更大的结构。

本文从一次滑动窗口乘加开始,依次解释卷积核、填充、步幅、特征图、通道、感受野和参数量。最后会把这些概念拼成一个可运行的 LeNet-5,并在 MNIST 上完成数据处理、训练与测试。示例采用 PyTorch,阅读代码只需要基础 Python 和线性代数知识。

概述

CNN 的核心可以归纳为三个与图像有关的先验。

第一,邻近像素通常比相距很远的像素联系更紧。边缘由相邻像素的亮度变化构成,纹理也会在小范围内重复,因此一层网络可以先观察局部区域。第二,同一种模式可能出现在画面的任意位置。检测左上角竖边的计算,也可以用来检测右下角的竖边,这就是权重共享。第三,视觉结构具有层次:浅层组合像素得到边缘和颜色对比,中层组合边缘得到纹理与部件,深层再把部件组合成对象。

局部连接减少了每个输出单元需要查看的输入,权重共享则让同一组参数覆盖所有空间位置。卷积因此具有平移等变性:输入平移时,输出特征图会相应平移。池化、步幅、边界填充和后续分类器会改变严格的等变关系,所以“CNN 天生具有完全平移不变性”并不准确。实际模型通常通过下采样、数据增强和更大的感受野获得有限的位移鲁棒性。

左侧全连接层把图像中的所有像素连接到每个输出,右侧卷积层让同一个三乘三卷积核在不同局部窗口上重复使用
全连接层为每一对输入与输出维护独立权重;卷积层只连接局部窗口,并在各空间位置共享同一组核参数。作者绘制

传统方式

传统机器学习方法的局限

深度学习普及之前,一套典型视觉系统会先做灰度化、去噪与尺度归一化,再由人工设计的算子提取边缘、角点、SIFT 或 HOG 特征,最后交给支持向量机、随机森林等分类器。这样的流水线依然适合数据较少、规则明确或需要强可解释性的任务,但特征提取器和分类器分别设计,前一阶段丢失的信息很难由后一阶段补回。

人工特征还会把设计者的假设固定在系统里。一个依赖轮廓的特征可能难以适应光照变化,一个在正视图上有效的模板也可能无法处理旋转和遮挡。每次任务变化都要重新选择特征、窗口与归一化方式,模型无法从最终误差反向调整整条特征链。

直接使用多层感知机也会遇到问题。把 224×224×3224\times224\times3 的图像展平后连接到 1024 个神经元,需要约 1.54 亿个权重,仅权重的 FP32 存储就接近 588 MiB。与此同时,展平操作抹去了上下左右关系:相邻像素和相隔数百行的像素在权重矩阵中没有结构差别。相比之下,一个从 3 通道映射到 64 通道的 3×33\times3 卷积层只有 64(3×3×3+1)=1,79264(3\times3\times3+1)=1,792 个参数。

CNN 与传统神经网络的区别

前馈神经网络

前馈神经网络描述的是计算图方向:信息从输入经过若干层流向输出,不在同一次前向计算中形成循环。多层感知机(Multilayer Perceptron,MLP)和常见 CNN 都属于前馈网络。日常语境里的“传统神经网络”通常特指由全连接层组成的 MLP。

全连接层先把输入写成向量 xRD\mathbf{x}\in\mathbb{R}^{D},再计算

y=σ(Wx+b),\mathbf{y}=\sigma(\mathbf{W}\mathbf{x}+\mathbf{b}),

其中 W\mathbf{W} 为权重矩阵,b\mathbf{b} 为偏置,σ\sigma 为非线性激活。每个输出都能读取全部输入,每一条连接也有独立权重。这种形式适合已经整理成固定长度向量的特征,却没有利用图像的空间结构。

卷积神经网络

卷积层保留输入的高、宽和通道轴。每个输出位置只读取一个局部窗口,同一卷积核随后移动到其他位置继续计算。学习到的权重数量由卷积核大小和通道数决定,与输入图像的高、宽无关,因此同一层可以处理不同空间尺寸的输入。

这两类层各有职责。卷积层擅长提取局部、可复用的空间模式;全连接层擅长整合固定长度的全局表示。经典 CNN 会在前半段反复使用卷积和池化,在尾部展平特征并进行全连接分类。现代网络常用全局平均池化替代大规模全连接层,但局部特征提取的逻辑仍然相同。

卷积运算

互相关运算

深度学习框架里名为“卷积”的操作通常实际计算互相关(cross-correlation)。给定二维输入 XX 和大小为 Kh×KwK_h\times K_w 的核 KK,在步幅为 1、没有填充的情况下,输出位置 (i,j)(i,j)

Yi,j=b+u=0Kh1v=0Kw1Ku,vXi+u,j+v.Y_{i,j} = b+\sum_{u=0}^{K_h-1}\sum_{v=0}^{K_w-1} K_{u,v}X_{i+u,j+v}.

计算时先从输入裁出与核同样大小的窗口,对应元素相乘,再把所有乘积与偏置相加。窗口向右移动得到下一列输出,到达边界后换行,最终形成一张新的二维数组。

严格的数学卷积会先把核沿两个轴翻转,互相关不会。对可训练卷积核而言,这个差异只改变参数的排列方式,反向传播仍能学到所需权重。为了与 PyTorch 等框架保持一致,下文继续沿用“卷积层”这个名称,并按互相关公式计算。

一个三乘三卷积核覆盖五乘五输入左上角,九个对应元素相乘求和得到输出左上角的负二,卷积核随后向右滑动
一次互相关计算只产生一个输出值。图中的竖直边缘核从左上角窗口得到 -2,继续滑动后生成完整的 3×3 特征图。作者绘制

卷积核

卷积核也叫滤波器。传统图像处理中,人们会手工指定核:均值核用于平滑,Sobel 核提取方向边缘,拉普拉斯核强调快速变化。CNN 把核内的数设为可训练参数,先随机初始化,再由损失函数的梯度更新。

浅层卷积核常会学到不同方向的边缘、颜色对比和简单纹理。后续卷积层的输入已经是特征图,核所响应的模式也会逐渐抽象。单独观察某个深层通道通常难以给出稳定的自然语言含义,因为有效表示往往分布在多个通道及其组合中。

核的空间尺寸常写成 1×11\times13×33\times35×55\times5。其中 1×11\times1 卷积虽然不查看相邻位置,却能在每个位置混合全部输入通道,因此经常用于调整通道数和构造瓶颈层。

卷积运算的通项公式

实际网络还包含批量、输入通道、输出通道、步幅、填充与膨胀。采用 PyTorch 的 NCHW 排列,输入为

XRN×Cin×H×W,X\in\mathbb{R}^{N\times C_{\mathrm{in}}\times H\times W},

权重为

KRCout×Cin×Kh×Kw.K\in\mathbb{R}^{C_{\mathrm{out}}\times C_{\mathrm{in}}\times K_h\times K_w}.

完整的输出元素可以写成

Yn,o,i,j=bo+c=0Cin1u=0Kh1v=0Kw1Ko,c,u,vXn,c,iSh+uDhPh,jSw+vDwPw.Y_{n,o,i,j} =b_o+ \sum_{c=0}^{C_{\mathrm{in}}-1} \sum_{u=0}^{K_h-1} \sum_{v=0}^{K_w-1} K_{o,c,u,v} X_{n,c,\,iS_h+uD_h-P_h,\,jS_w+vD_w-P_w}.

SS 表示步幅,PP 表示填充,DD 表示膨胀率。落到原输入边界之外的 XX 按所选填充规则处理;零填充时这些位置取 0。分组卷积会限制每个输出通道读取的输入通道集合,普通卷积的 groups=1,上式正是这一常见情况。

对应的 PyTorch 层可以直接验证形状:

import torch
from torch import nn

x = torch.randn(8, 3, 64, 64)  # N, C_in, H, W
conv = nn.Conv2d(
    in_channels=3,
    out_channels=16,
    kernel_size=3,
    stride=1,
    padding=1,
)
y = conv(x)

print(y.shape)  # torch.Size([8, 16, 64, 64])

卷积运算后的图像变化

卷积首先会改变空间尺寸。单个轴的输出长度由下面的公式决定:

Hout=H+2PhDh(Kh1)1Sh+1,H_{\mathrm{out}} = \left\lfloor \frac{H+2P_h-D_h(K_h-1)-1}{S_h}+1 \right\rfloor, Wout=W+2PwDw(Kw1)1Sw+1.W_{\mathrm{out}} = \left\lfloor \frac{W+2P_w-D_w(K_w-1)-1}{S_w}+1 \right\rfloor.

例如,5×55\times5 输入经过 3×33\times3、步幅 1、无填充的卷积后变成 3×33\times3。若四周填充 1 个像素,输出仍是 5×55\times5;再把步幅改为 2,输出会变成 3×33\times3。公式中的向下取整说明,最后一个放不下完整采样窗口的位置会被舍去。

卷积还会改变通道数和值的含义。原始 RGB 通道记录颜色强度,输出通道则表示各卷积核在不同位置的响应。经过激活函数后,数值不再需要落在图像像素的显示范围内。把中间结果可视化时通常还要选择通道并做归一化,因此“卷积后的图像”更准确的称呼是特征图张量。

填充与步幅

不填充时,卷积核必须完整落在输入内部,空间尺寸会逐层缩小,边缘像素参与计算的次数也少于中心像素。填充(padding)在输入四周补充额外值,使卷积核可以覆盖边界附近。零填充最常见;反射填充和复制填充在图像复原等对边界连续性敏感的任务中也会使用。

当核尺寸为奇数、膨胀率和步幅都为 1 时,设置

P=K12P=\frac{K-1}{2}

可以保持空间尺寸不变。3×33\times3 核对应 padding=15×55\times5 核对应 padding=2。偶数核或大于 1 的步幅可能需要非对称填充,不能直接套用这个简式。

步幅(stride)决定窗口每次移动多少格。步幅 2 大致把高和宽各缩小一半,计算量随之下降,但采样前若没有足够的低通约束,也可能丢失细小结构并产生混叠。分类模型常接受这种取舍;分割、关键点定位等密集预测任务会更谨慎地控制下采样次数。

特征图

一个输出通道对应一张特征图。特征图上的每个位置都使用同一个核计算,因此数值可以理解为“这个局部窗口与当前核的匹配程度”。再经过 ReLU、Tanh 等激活函数,网络便能组合多个线性响应,表达更复杂的模式。

卷积运算本身仍是仿射变换。若连续堆叠卷积却不加入非线性,多层最终可以合并为一次线性运算,深度不会带来预期的表达能力。经典结构常写成“卷积、激活、池化”,现代结构还会加入归一化、残差连接或注意力模块。

卷积核组

单个核只生成一个输出通道。为了同时检测多种模式,卷积层会维护 CoutC_{\mathrm{out}} 组核;每组核又包含 CinC_{\mathrm{in}} 个二维切片,分别处理输入通道。每组切片的结果沿通道求和,加上一个偏置,得到一张输出特征图。

以 RGB 输入和 64 个输出通道为例,权重形状是 (64,3,3,3)(64,3,3,3)。这里有 64 组核,每组核横跨 3 个输入通道,空间大小为 3×33\times3。它产生 64 张特征图,而不是让 R、G、B 各自独立产生 64 张图。

卷积核组也解释了通道混合。即便空间位置不变,1×11\times1 卷积仍可通过形状为 (Cout,Cin,1,1)(C_{\mathrm{out}},C_{\mathrm{in}},1,1) 的权重,在每个像素位置对输入通道做不同线性组合。

感受野

某个特征图元素能够追溯到的输入区域称为感受野。第一层 3×33\times3 卷积的单个输出只依赖输入中的 3×33\times3 区域;继续叠加卷积和池化后,一个深层元素会间接读取更大的范围。

设第 ll 层相邻输出元素在原图上的间隔为 jlj_l,理论感受野边长为 rlr_l,从 j0=r0=1j_0=r_0=1 开始,则

jl=jl1Sl,j_l=j_{l-1}S_l, rl=rl1+(Kl1)Dljl1.r_l=r_{l-1}+(K_l-1)D_lj_{l-1}.

例如,先做 3×33\times3、步幅 1 的卷积,再做 2×22\times2、步幅 2 的池化,最后再做一次 3×33\times3 卷积,三个阶段的感受野依次为 3、4 和 8,最后一层相邻元素在原图上相隔 2 个像素。

连续两层 3×33\times3、步幅 1 的卷积拥有 5×55\times5 的理论感受野,三层则达到 7×77\times7。这种堆叠会插入更多非线性,参数也往往少于一层大核卷积。理论感受野表示可能影响输出的最大范围;训练后的有效感受野通常更集中在中心区域,两者需要区分。

可训练参数

普通二维卷积的参数量为

Cout(CinKhKw+1),C_{\mathrm{out}} \left( C_{\mathrm{in}}K_hK_w+1 \right),

括号里的 1 来自每个输出通道的偏置。若 bias=False,去掉这一项。使用 GG 组分组卷积时,每个输出通道只连接 Cin/GC_{\mathrm{in}}/G 个输入通道,参数量相应变成

Cout(CinGKhKw+1).C_{\mathrm{out}} \left( \frac{C_{\mathrm{in}}}{G}K_hK_w+1 \right).

参数量不随输入高、宽变化,但计算量会随输出位置数量增加。前面的 3643\to643×33\times3 卷积有 1,792 个参数;一个 6412864\to1283×33\times3 卷积则有 128(64×9+1)=73,856128(64\times9+1)=73,856 个参数。检查模型时应同时看参数、激活张量和乘加次数,不能只凭模型文件大小判断推理成本。

拉普拉斯算子

拉普拉斯算子是理解卷积核作用的一个直观例子。二维离散图像常用下面的四邻域核近似二阶导数:

KLaplace=[010141010].K_{\mathrm{Laplace}} = \begin{bmatrix} 0 & 1 & 0 \\ 1 & -4 & 1 \\ 0 & 1 & 0 \end{bmatrix}.

平坦区域的中心像素与邻居相近,卷积结果接近 0;亮度快速变化的边缘会产生较大的正负响应。下面的代码把它当成固定权重应用到单通道图像:

import torch
import torch.nn.functional as F

# gray 的形状为 (N, 1, H, W),数值已经转换为 float。
gray = torch.rand(1, 1, 128, 128)
laplace = torch.tensor(
    [[0.0, 1.0, 0.0],
     [1.0, -4.0, 1.0],
     [0.0, 1.0, 0.0]],
).reshape(1, 1, 3, 3)

response = F.conv2d(gray, laplace, padding=1)
edge_strength = response.abs()

这里没有优化器,laplace 也不参与训练。CNN 的区别在于让大量卷积核从数据中学习。训练后的一些浅层核可能呈现类似边缘算子的结构,但网络不受预设方向和系数限制。

数据输入输出

单通道图像

灰度图在文件或数组里常表示为 (H,W)(H,W),卷积层仍需要显式的通道轴。单张灰度图应整理成 (1,H,W)(1,H,W),批量输入则是 (N,1,H,W)(N,1,H,W)。如果手里只有二维张量,可以这样补齐维度:

image = torch.rand(28, 28)
single = image.unsqueeze(0)       # (1, 28, 28),补通道轴
batch = single.unsqueeze(0)       # (1, 1, 28, 28),再补批量轴

通道数必须与 nn.Conv2d(in_channels=...) 一致。把 (H,W)(H,W) 直接传入模型,或把高误当成通道,都会导致形状错误。

多通道图像

彩色图像通常包含 R、G、B 三个通道。Pillow 和 NumPy 常采用 (H,W,C)(H,W,C),PyTorch 卷积默认采用 (C,H,W)(C,H,W)torchvision.transforms.ToTensor() 会完成轴顺序转换,并把常见的 8 位像素缩放到 [0,1][0,1]

三个颜色通道应使用与预训练或训练配置一致的均值和标准差分别归一化。额外的 Alpha 通道通常需要显式丢弃或合成到底色,不能在模型期待 3 通道时直接传入 4 通道图像。

多通道输入

中间特征张量的通道数往往远大于 3。对于某个输出通道,卷积层会在每个输入通道上分别做二维互相关,再把结果相加。一个处理 RGB 的 3×33\times3 核实际包含 3×3×3=273\times3\times3=27 个权重;一个处理 64 通道特征的同尺寸核则包含 576 个权重。

因此,“一个核生成一张特征图”里的“一个核”指完整的三维权重组,而非某个输入通道上的二维切片。标准卷积通过这次求和同时完成空间模式检测与跨通道融合。

多通道输出

输出通道数由卷积核组的数量决定。输入的 CinC_{\mathrm{in}} 和输出的 CoutC_{\mathrm{out}} 可以不同:Conv2d(3, 64, 3) 把 3 通道图像映射成 64 通道特征,Conv2d(64, 128, 3) 再映射成 128 通道。

对象形状含义
单张灰度图(1, H, W)1 个输入通道
RGB 批量(N, 3, H, W)N 张三通道图像
卷积权重(C_out, C_in, K_h, K_w)每个输出核横跨全部输入通道
卷积输出(N, C_out, H_out, W_out)每个核产生一张特征图

批量操作

训练时,DataLoader 会把多张 (C,H,W)(C,H,W) 图像堆叠成 (N,C,H,W)(N,C,H,W)。卷积层对批量中的每个样本使用同一组权重,样本之间不会在卷积运算里相互混合。批量大小改变显存占用、吞吐量和梯度估计的统计性质,不会改变模型参数量。

conv = nn.Conv2d(3, 32, kernel_size=3, padding=1)
x = torch.randn(16, 3, 128, 128)
y = conv(x)

assert y.shape == (16, 32, 128, 128)

损失函数通常先对每个样本产生损失,再取批量均值。若把 reductionmean 改成 sum,梯度尺度会随批量大小变化,学习率也可能需要相应调整。

典型网络结构

概述

经典图像分类 CNN 可以写成下面的流水线:

输入 N×C×H×W
  -> [卷积 -> 激活 -> 池化] × 若干阶段
  -> 展平
  -> 全连接层
  -> 类别 logits

前几个阶段逐步减小空间尺寸、增加通道数,让局部模式组合成更大范围的表示。展平和全连接层把空间特征汇总为类别分数。LeNet-5 正是这条路线的代表;VGG 延续了规则堆叠的思路,ResNet 则加入残差连接,使更深网络更容易训练。

现代架构常用带步幅卷积完成下采样,用全局平均池化把每个通道压成一个数,并在卷积之后加入归一化。理解经典的卷积、池化与全连接分工,仍是阅读这些网络的基础。

卷积层

卷积层负责学习局部模式并生成多通道特征。它的构造参数应与设计中的张量形状一一对应:in_channels 必须匹配上一层输出通道,out_channels 决定本层核的数量,kernel_sizestridepaddingdilation 共同决定空间尺寸与感受野。

卷积之后通常紧跟激活函数。LeNet-5 使用接近双曲正切的饱和激活,现代分类网络更常使用 ReLU、GELU 或 SiLU。若卷积后接 BatchNorm,卷积偏置通常可以关闭,因为归一化层已有可学习的平移参数。

调试网络时,先在纸上或表格里记录每层的 (C,H,W)(C,H,W),再用随机张量跑一次前向传播。很多“矩阵无法相乘”错误都能追溯到卷积输出尺寸计算错误,或展平后的特征数与第一层线性层不一致。

池化层

概述

池化(pooling)在局部窗口内汇总数值,用于降低空间分辨率、扩大后续层感受野并减少计算。常见池化没有可训练参数。它引入的局部汇总能缓和小幅位移带来的变化,但仍无法保证任意平移下输出完全不变。

池化方法

最大池化取窗口内最大值,保留最强响应;平均池化取均值,形成更平滑的摘要。全局平均池化则把整张特征图压成每个通道一个数,常放在现代分类网络的末端。自适应池化允许直接指定输出尺寸,框架会自动选择窗口和步幅。

最大池化适合回答“某个模式是否在这块区域出现”,平均池化保留区域整体响应。哪一种更合适取决于任务和架构,不能只凭一条固定经验决定。

池化过程

对下面的 4×44\times4 输入使用 2×22\times2 窗口和步幅 2:

X=[1320465102871349].X= \begin{bmatrix} 1 & 3 & 2 & 0 \\ 4 & 6 & 5 & 1 \\ 0 & 2 & 8 & 7 \\ 1 & 3 & 4 & 9 \end{bmatrix}.

四个窗口互不重叠,最大池化和平均池化分别得到

MaxPool(X)=[6539],AvgPool(X)=[3.521.57].\operatorname{MaxPool}(X)= \begin{bmatrix} 6 & 5 \\ 3 & 9 \end{bmatrix}, \qquad \operatorname{AvgPool}(X)= \begin{bmatrix} 3.5 & 2 \\ 1.5 & 7 \end{bmatrix}.

池化的输出尺寸沿用卷积的窗口公式,只是通常没有膨胀。窗口大于步幅时会重叠,小于或等于步幅时下采样更明显。

多通道处理

标准二维池化逐通道独立进行,不在通道之间求最大值或平均值。输入 (N,C,H,W)(N,C,H,W) 经过池化后仍有 CC 个通道,只改变 HHWW。若需要混合或压缩通道,应使用卷积、线性层或其他显式的通道运算。

pool = nn.MaxPool2d(kernel_size=2, stride=2)
x = torch.randn(8, 64, 32, 32)
y = pool(x)

assert y.shape == (8, 64, 16, 16)

全连接层

概述

卷积和池化得到的是带空间结构的特征,全连接层负责把这些特征组合成固定长度输出。分类任务的最后一层通常为每个类别产生一个 logit。logit 是未归一化分数,可以为任意实数;训练时的 CrossEntropyLoss 会在内部完成 log_softmax,模型前向方法无需先调用 Softmax。

大尺寸特征图接全连接层会产生大量参数。经典 LeNet 的输入很小,因此成本可控;现代网络常先做全局平均池化,再接一层较小的线性分类器。

展平

展平把每个样本的 (C,H,W)(C,H,W) 合并为长度 CHWCHW 的向量,同时保留批量轴:

x = torch.randn(32, 16, 5, 5)
features = torch.flatten(x, start_dim=1)

assert features.shape == (32, 400)

若从 start_dim=0 开始,会把 32 个样本也混成一个向量,破坏批量语义。使用 nn.Flatten()torch.flatten(x, 1) 可以避免手写批量大小。

线性计算

PyTorch 的 nn.Linear(D_in, D_out) 对每个样本计算

Y=XW+b,Y=XW^\top+b,

其中 XX 的形状为 (N,Din)(N,D_{\mathrm{in}})WW 的形状为 (Dout,Din)(D_{\mathrm{out}},D_{\mathrm{in}}),输出为 (N,Dout)(N,D_{\mathrm{out}})。参数量是 Dout(Din+1)D_{\mathrm{out}}(D_{\mathrm{in}}+1)。这个公式也解释了为什么展平特征数一旦算错,下一层就会报告矩阵维度不匹配。

实现 LeNet-5 网络

概述

LeNet-5 由 Yann LeCun、Léon Bottou、Yoshua Bengio 和 Patrick Haffner 在 1998 年的文档识别论文中系统介绍。原网络接收 32×3232\times32 灰度图,依次经过 C1、S2、C3、S4、C5、F6 与输出层。MNIST 原图为 28×2828\times28,在四周补 2 个像素后正好符合这一路径。

原始 LeNet-5 的 C3 只连接上一层的部分特征图,S2 和 S4 还包含可训练系数与偏置,输出使用径向基函数。下面的教学实现保留空间尺寸、Tanh、平均池化和 C5 卷积,简化为全通道连接,并使用 10 个线性 logits 配合交叉熵。这是今天常见的 PyTorch 版本,参数量为 61,706;它与历史实现非常接近,但不能当作逐参数复刻。

LeNet-5 教学实现从一通道三十二乘三十二输入开始,依次经过六通道卷积、平均池化、十六通道卷积、平均池化、一百二十通道卷积、八十四维全连接层和十类输出
LeNet-5 的空间尺寸从 32×32 逐步缩小到 1×1,通道数从 1 增至 120,最后由全连接层产生 10 个类别 logits。根据 LeCun 等人的 LeNet-5 结构重绘
运算单样本输出参数
输入MNIST 四周填充 21×32×320
C15×5 Conv, 1→66×28×28156
S22×2 AvgPool6×14×140
C35×5 Conv, 6→1616×10×102416
S42×2 AvgPool16×5×50
C55×5 Conv, 16→120120×1×148120
F6Linear, 120→848410164
输出Linear, 84→1010850

实现网络结构

先定义网络并用随机输入检查输出形状。C5 使用 5×55\times5 卷积覆盖 S4 的整张 5×55\times5 特征图,因此输出自然变成 (120,1,1)(120,1,1)

import torch
from torch import nn


class LeNet5(nn.Module):
    def __init__(self) -> None:
        super().__init__()
        self.features = nn.Sequential(
            nn.Conv2d(1, 6, kernel_size=5),
            nn.Tanh(),
            nn.AvgPool2d(kernel_size=2, stride=2),
            nn.Conv2d(6, 16, kernel_size=5),
            nn.Tanh(),
            nn.AvgPool2d(kernel_size=2, stride=2),
            nn.Conv2d(16, 120, kernel_size=5),
            nn.Tanh(),
        )
        self.classifier = nn.Sequential(
            nn.Flatten(),
            nn.Linear(120, 84),
            nn.Tanh(),
            nn.Linear(84, 10),
        )

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        x = self.features(x)
        return self.classifier(x)


model = LeNet5()
dummy = torch.randn(4, 1, 32, 32)
logits = model(dummy)

assert logits.shape == (4, 10)
print(sum(parameter.numel() for parameter in model.parameters()))
# 61706

forward() 返回 logits,不调用 Softmax。推理时若需要概率,可以在模型外使用 logits.softmax(dim=1);训练时直接把 logits 和整数类别标签交给 nn.CrossEntropyLoss()

也可以临时打印各阶段形状来定位错误:

x = torch.randn(1, 1, 32, 32)
for layer in model.features:
    x = layer(x)
    print(f"{layer.__class__.__name__:>10}: {tuple(x.shape)}")

这段检查应依次看到空间尺寸 28、14、10、5 和 1。若输入仍是 MNIST 原始的 28×2828\times28,第二次池化后只剩 4×44\times4,C5 的 5×55\times5 核便无法执行;这正是数据变换中加入 Pad(2) 的原因。

数据处理与训练

下面是一份完整训练脚本。训练集和测试集使用同一组填充、张量转换与归一化操作。DataLoader 负责组成 NCHW 批量,训练循环负责清空梯度、前向计算、反向传播和参数更新,评估循环则关闭梯度记录。

import torch
from torch import nn
from torch.utils.data import DataLoader
from torchvision import datasets, transforms


def choose_device() -> torch.device:
    if torch.cuda.is_available():
        return torch.device("cuda")
    if torch.backends.mps.is_available():
        return torch.device("mps")
    return torch.device("cpu")


def train_one_epoch(
    model: nn.Module,
    loader: DataLoader,
    criterion: nn.Module,
    optimizer: torch.optim.Optimizer,
    device: torch.device,
) -> tuple[float, float]:
    model.train()
    loss_sum = 0.0
    correct = 0
    sample_count = 0

    for images, labels in loader:
        images = images.to(device, non_blocking=True)
        labels = labels.to(device, non_blocking=True)

        optimizer.zero_grad(set_to_none=True)
        logits = model(images)
        loss = criterion(logits, labels)
        loss.backward()
        optimizer.step()

        batch_size = labels.size(0)
        loss_sum += loss.item() * batch_size
        correct += (logits.argmax(dim=1) == labels).sum().item()
        sample_count += batch_size

    return loss_sum / sample_count, correct / sample_count


@torch.inference_mode()
def evaluate(
    model: nn.Module,
    loader: DataLoader,
    device: torch.device,
) -> float:
    model.eval()
    correct = 0
    sample_count = 0

    for images, labels in loader:
        images = images.to(device, non_blocking=True)
        labels = labels.to(device, non_blocking=True)
        logits = model(images)
        correct += (logits.argmax(dim=1) == labels).sum().item()
        sample_count += labels.size(0)

    return correct / sample_count


def main() -> None:
    torch.manual_seed(42)
    device = choose_device()
    pin_memory = device.type == "cuda"

    transform = transforms.Compose([
        transforms.Pad(2),
        transforms.ToTensor(),
        transforms.Normalize((0.1307,), (0.3081,)),
    ])

    train_set = datasets.MNIST(
        root="data",
        train=True,
        download=True,
        transform=transform,
    )
    test_set = datasets.MNIST(
        root="data",
        train=False,
        download=True,
        transform=transform,
    )

    train_loader = DataLoader(
        train_set,
        batch_size=128,
        shuffle=True,
        num_workers=2,
        pin_memory=pin_memory,
    )
    test_loader = DataLoader(
        test_set,
        batch_size=256,
        shuffle=False,
        num_workers=2,
        pin_memory=pin_memory,
    )

    model = LeNet5().to(device)
    criterion = nn.CrossEntropyLoss()
    optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)

    for epoch in range(1, 6):
        train_loss, train_accuracy = train_one_epoch(
            model,
            train_loader,
            criterion,
            optimizer,
            device,
        )
        test_accuracy = evaluate(model, test_loader, device)
        print(
            f"epoch={epoch} "
            f"loss={train_loss:.4f} "
            f"train_acc={train_accuracy:.2%} "
            f"test_acc={test_accuracy:.2%}"
        )


if __name__ == "__main__":
    main()

在常见环境中训练数个 epoch 后,这个小模型通常可以达到约 98% 至 99% 的 MNIST 测试准确率。具体数值会受 PyTorch 版本、设备、随机初始化和优化器设置影响,因此应以实际运行结果为准。若希望严格复现实验,还要固定数据加载工作进程的随机种子,并记录依赖版本与硬件信息。

训练时最常见的错误集中在四处:忘记把 MNIST 填充到 32×3232\times32;把 NHWC 数据直接交给 NCHW 卷积;展平时误把批量轴合并;在 CrossEntropyLoss 之前重复做 Softmax。沿着本文的逐层形状表检查,通常能很快定位这些问题。

从 LeNet-5 再向后学习 VGG、ResNet 或现代视觉骨干时,卷积核可能换成分组卷积,池化可能换成带步幅卷积,全连接层也可能被全局平均池化取代。底层账本仍然是同一套:每一层读取什么形状,输出什么形状,拥有多少参数,以及一个输出最终能看到原图多大的区域。掌握这四个问题,就掌握了阅读卷积网络结构的主线。

参考文献

  1. Y. LeCun, L. Bottou, Y. Bengio, and P. Haffner. Gradient-Based Learning Applied to Document Recognition. Proceedings of the IEEE, 1998. DOI: 10.1109/5.726791
  2. V. Dumoulin and F. Visin. A Guide to Convolution Arithmetic for Deep Learning. arXiv, 2016
  3. I. Goodfellow, Y. Bengio, and A. Courville. Deep Learning, Chapter 9: Convolutional Networks. MIT Press, 2016
  4. PyTorch Contributors. Conv2d Documentation. PyTorch Documentation