☰
CNN入门实战:核心组件、特征图尺寸计算与LeNet-5/AlexNet解析
2026/10/21 5:56:27 网站建设 项目流程

很多初学者在 B 站、CSDN、知乎上看了大量卷积神经网络(CNN)的视频和文章,每个组件都能说出名字:卷积层、池化层、激活函数、全连接层,甚至 LeNet-5、AlexNet 也背得滚瓜烂熟。但真到自己动手写代码、设计一个网络时,立刻卡住——卷积核大小怎么选?padding 加多少?特征图尺寸到底是怎么从 32 变成 28 再变成 14 的?全连接层的输入维度又是怎么算出来的?

说白了:概念都认识,但串不起来。这正是这篇文章要解决的问题。

我写这篇教程的判断很明确:学 CNN,最重要的不是记住那些层叫什么,而是建立一条“特征图流动”的主线。从输入图像开始,数据经过每一个层之后,尺寸、通道数、参数量发生了什么变化,为什么这样设计,这才是你真正需要掌握的东西。读完这篇文章,你能独立完成三件事:理解并复述 LeNet-5 和 AlexNet 的完整结构;手写特征图尺寸计算公式;用 PyTorch 从零实现并训练一个 CNN 模型完成手写数字识别。

文章会按照“问题 → 概念 → 公式 → 经典网络拆解 → 完整代码 → 验证 → 排错 → 最佳实践”的顺序展开,建议先收藏再阅读。

1. 这篇文章真正要解决的问题

先说说全连接网络处理图像时遇到的困境,你就知道 CNN 为什么出现了。

假设输入是一张 32x32 像素的灰度图,如果用全连接网络,输入层就要展开成 32x32=1024 个神经元。如果隐藏层有 1024 个神经元,这一层的权重参数量就是 1024x1024,约 105 万。这还算能接受。但如果是 224x224 的彩色图呢?输入维度是 224x224x3=150528,同样 1024 个神经元的隐藏层,权重参数量直接飙到 1.5 亿。训练这样的模型,不仅需要海量数据,而且极其容易过拟合。

更关键的问题是:全连接层忽略了图像的空间结构。一张猫的图片,把像素顺序打乱后,人眼能看出来但模型就认不出来了,因为像素之间的相对位置关系全丢了。但图像识别的本质恰恰是局部纹理、边缘、形状这些空间特征。

CNN 就是为这两大问题而生的:

CNN 的核心设计思想:利用卷积操作提取局部特征,通过参数共享大幅减少参数量,通过层级结构从低层边缘到高层语义逐级抽象,最终完成分类或检测任务。

这篇文章适合以下读者:

  • 学完了 Python 基础,想进入深度学习但被各种概念绕晕的初学者。
  • 看完视频教程觉得自己懂了,但一连代码就报维度错误的新手。
  • 想系统梳理 CNN 核心组件、经典网络结构,准备面试或写毕业论文的技术人。

不适合谁?如果你已经能独立实现并训练 ResNet、读得懂源码,那么这篇基础教程对你来说过于入门了。

2. CNN 五个核心组件的原理与对比

CNN 的标准结构可以概括成一句话:通过卷积层和池化层反复提取特征,通过激活函数引入非线性,最后用全连接层输出分类结果。

2.1 卷积层

卷积层是 CNN 的灵魂。它的作用是提取图像的局部特征。

通俗地理解:卷积核就是一个小的滑动窗口(比如 3x3 或 5x5),在图像上从左到右、从上到下地滑动,每次与覆盖的区域做“逐元素相乘再求和”,生成一个新的值。滑动完成后,就得到了一张特征图(Feature Map)。

三个必须掌握的概念:

  • 卷积核(Kernel / Filter):一个小的权重矩阵,它学习的是某种特定的局部模式,比如边缘、角点、纹理。
  • 步长(Stride):卷积核每次滑动的像素数。步长为 1,滑动得密集,特征图大;步长为 2,特征图缩小一半。
  • 填充(Padding):在图像边缘补一圈或多圈 0。作用是防止边缘信息丢失,同时控制输出尺寸。当 padding=1、kernel_size=3、stride=1 时,输出尺寸和输入尺寸一致,这种操作在同为 k 的卷积中很常用。

为什么需要参数共享?因为一个卷积核在整个图像上滑动时,权重是相同的。这意味着无论图像多大,一个卷积核只有 k×k 个参数。假设用 32 个 3x3 卷积核,参数只有 32x9=288 个,远小于全连接的做法。

2.2 池化层

池化层的作用是降采样,也就是缩小特征图的尺寸,同时保留最重要的信息。

最常用的是最大池化(Max Pooling):在 2x2 的窗口内取最大值,窗口滑动步长为 2,特征图的宽高各缩小一半。为什么要取最大值?因为对卷积层输出的特征来说,某个位置数值越大,代表该位置对某种模式的响应越强,保留这个最大响应可以保留最显著的特征。

池化层没有需要学习的参数,这是它和卷积层的本质区别。它只做一件事:压缩信息。这样做的好处有三点:

  1. 降低计算量,让后续层的计算更轻量。
  2. 扩大感受野,让后面层能看到更大范围的原始图像区域。
  3. 增强平移不变性,目标稍微偏移一点,池化结果变化不大。

2.3 激活函数

卷积和池化本质都是线性操作(卷积是乘加,池化是取最大/平均)。如果只有这些操作,无论网络多深,整体依然是线性的,表达能力非常有限。

激活函数的作用就是引入非线性,让网络能够拟合复杂的映射关系。

CNN 中最常用的是 ReLU(Rectified Linear Unit):

f(x) = max(0, x)

x 大于 0 时保持原值,小于 0 时变成 0。ReLU 的优势是计算简单、在正区间梯度恒为 1,能有效缓解梯度消失问题。相比之下,Sigmoid 在输入值很大或很小时梯度接近 0,多个层叠加后梯度连乘会越来越小,导致深层网络难以训练。

这里给一个直观对比:

激活函数公式输出范围主要优点主要缺点
Sigmoid1/(1+e^-x)(0, 1)输出可解释为概率梯度消失、计算量大
Tanh(e^x - e^-x)/(e^x + e^-x)(-1, 1)均值接近 0,梯度更强仍有梯度消失问题
ReLUmax(0, x)[0, +∞)计算快、正区间梯度恒定神经元可能“死亡”
Leaky ReLUmax(0.01x, x)(-∞, +∞)避免神经元死亡需要多调一个超参数

2.4 全连接层

经过多次卷积和池化后,特征图已经高度抽象,这时需要把它们“展平”成一维向量,然后送入全连接层。

全连接层的作用是把前面提取到的特征映射到样本标记空间,完成分类。最后一个全连接层的神经元数量通常等于类别数。

以 LeNet-5 为例,最后一层输出 10 个值,分别对应 0-9 十类数字。配合 Softmax 函数,这 10 个值可以变成 10 个概率,概率最高的类别就是预测结果。

2.5 五者关系一句话总结

卷积层负责“找特征”,池化层负责“压缩信息”,激活函数负责“引入非线性”,全连接层负责“做决策”。整体流程是:

输入图像 -> 卷积 -> 激活 -> 池化 -> 卷积 -> 激活 -> 池化 -> 展平 -> 全连接 -> 输出

2.6 五者对比表

组件核心功能是否有可学习参数对特征图尺寸影响常见配置
卷积层提取局部特征是取决于核大小、步长、padding3x3、padding=1、stride=1
池化层降采样、压缩信息否通常宽高减半2x2、stride=2、MaxPool
激活函数引入非线性否尺寸不变ReLU
全连接层分类决策是展平后映射到类别128/64/类别数
Softmax输出概率分布否尺寸不变与类别数一致

这张表建议保存下来,后面分析 LeNet-5 和 AlexNet 会反复用到。

3. 特征图尺寸计算:CNN 最容易算错的地方

很多初学者写代码报错,80% 的“维度不匹配”问题都出在特征图尺寸算错。这一节把这个基本功彻底搞定。

3.1 输出尺寸的标准公式

H_out = floor((H_in + 2 * padding - dilation * (kernel_size - 1) - 1) / stride + 1) W_out = floor((W_in + 2 * padding - dilation * (kernel_size - 1) - 1) / stride + 1)

绝大多数场景下 dilation=1,公式可以简化为:

H_out = floor((H_in + 2 * padding - kernel_size) / stride + 1)

其中 floor 表示向下取整。这个公式适用于卷积层和池化层。

3.2 三个手算案例

案例一:输入 32x32,卷积核 5x5,padding=0,stride=1。

(32 + 0 - 5) / 1 + 1 = 28

所以输出 28x28。

案例二:输入 28x28,池化核 2x2,stride=2。

(28 + 0 - 2) / 2 + 1 = 14

所以输出 14x14。

案例三:输入 224x224,卷积核 3x3,padding=1,stride=1。

(224 + 2 - 3) / 1 + 1 = 224

So-called “same padding”,输出尺寸保持不变,所以 padding=1 搭配 kernel=3 时经常被用来保持尺寸不变。

3.3 用 Python 验证计算

# 文件路径:feature_map_size.py def calc_output_size(input_size, kernel_size, stride=1, padding=0, dilation=1): """ 计算卷积/池化后的特征图尺寸 参数: input_size: 输入尺寸(宽或高) kernel_size: 卷积核尺寸 stride: 步长 padding: 填充大小 dilation: 空洞卷积系数,默认为1 返回: 输出尺寸 """ output_size = ( input_size + 2 * padding - dilation * (kernel_size - 1) - 1 ) // stride + 1 return output_size if __name__ == "__main__": # LeNet-5 中 C1 层:输入32x32,5x5卷积,输出28x28 print(calc_output_size(32, 5, stride=1, padding=0)) # 28 # 池化层:输入28x28,2x2池化,输出14x14 print(calc_output_size(28, 2, stride=2, padding=0)) # 14 # 保持尺寸不变的卷积:输入224x224,3x3卷积,padding=1 print(calc_output_size(224, 3, stride=1, padding=1)) # 224

运行结果:

28 14 224

在实际项目中,你不会每一步都手算,但必须在设计网络时对每层的特征图尺寸变化有预估。因为这会直接影响显存占用。例如一张 224x224 的彩色图经过 64 个卷积核提取特征后,特征图是 64x224x224,占用约 224x224x64x4 字节 ≈ 12.8MB,如果 batch size 是 64,仅这一层就是 800MB 的显存开销。算不清楚,后面就是无休止的 OOM 报错。

4. LeNet-5 逐层拆解:CNN 的教科书

LeNet-5 是 Yann LeCun 在 1998 年提出的经典卷积神经网络,最初用于手写数字识别。它的结构简单清晰,是理解 CNN 架构的最佳起点。

4.1 完整结构表

官方 LeNet-5 输入是 32x32 的灰度图,注意 MNIST 原始图片是 28x28,所以复现时会在前面补齐或调整网络输入尺寸。下面按原始结构看:

层名称类型核/池化配置输出尺寸参数量说明
INPUT输入层无1x32x32灰度图
C1卷积层6个5x5核,stride=16x28x286x(5x5+1)≈156
S2池化层2x2,stride=2,平均池化6x14x142x1x6≈12
C3卷积层16个5x5核,stride=116x10x1016x(6x5x5+1)≈2416
S4池化层2x2,stride=216x5x52x1x16≈32
C5卷积层120个5x5核120x1x1120x(16x5x5+1)≈48120
F6全连接层84个神经元84120x84+84≈10164
OUTPUT全连接层10个神经元1084x10+10≈850

总参数量约 6 万个。这个数量级在今天看来非常小,但在 90 年代已经能实现银行支票手写数字识别。

4.2 结构设计思想

LeNet-5 确立了 CNN 的标准范式:特征提取 + 分类。

前半段(C1-S2-C3-S4-C5)是特征提取器,通过卷积不断加深通道数、缩小空间尺寸,从原始像素中提取越来越抽象的特征。后半段(F6-OUTPUT)是分类器,把特征映射成类别概率。

注意 S2 用的是平均池化(平均后加可学习的权重和偏置),而现代 CNN 更常用最大池化。这说明“池化方式的选择”在那个年代已经在探索了。

4.3 动手计算一遍 C1 层

输入:1x32x32,卷积核 6 个,大小为 5x5,stride=1,padding=0。

输出尺寸按公式:

H_out = (32 + 0 - 5) / 1 + 1 = 28

通道数变为 6。所以 C1 输出是 6x28x28。

4.4 LeNet-5 的局限

  • 使用 Sigmoid / Tanh 激活,存在一定的梯度消失问题。
  • 均值池化在现代实现中不如最大池化用得普遍。
  • 输入尺寸 32x32 偏小,处理更大图像需要调整结构。
  • 没有 Dropout 等正则化手段,在复杂任务上容易过拟合。

尽管如此,LeNet-5 依然是理解 CNN“如何从原始像素一步步变化到分类结果”的最佳范例。

5. AlexNet 带来三个关键变革:为什么它成了分水岭

如果说 LeNet-5 证明了 CNN 的价值,AlexNet 则让 CNN 真正“出圈”。2012 年,AlexNet 在 ImageNet 图像分类竞赛中以巨大优势夺冠,错误率远低于传统方法,掀起了深度学习革命。

AlexNet 的结构比 LeNet-5 深很多:5 个卷积层 + 3 个全连接层,总参数量约 6000 万,使用两块 GPU 并行训练。结构本身值得了解,但更值得关注的是它带来的三个可以复用到今天的技术变革。

5.1 ReLU 激活函数取代 Sigmoid

AlexNet 使用 ReLU 替代 Sigmoid,从根本上缓解了梯度消失问题。ReLU 在正区间的导数是常数 1,即使网络很深,反向传播时梯度也不会因为连乘不断衰减。这让训练深层网络变得可行,而且收敛速度快很多。

这一点在 LeNet-5 时代是做不到的。如果你用 Sigmoid 去训练一个 8 层以上的 CNN,大概率会观察到 loss 下降极慢甚至不下降。

5.2 Dropout 随机失活

AlexNet 在全连接层中使用 Dropout:训练时每次随机“丢弃”一部分神经元(输出置为 0),让网络不能依赖某几个特定神经元,从而学习到更鲁棒的特征。

Dropout 是一种正则化方法,能有效缓解过拟合。模型有 6000 万参数,而 ImageNet 数据集虽然大,但模型容量更高,如果没有 Dropout,训练集上会很快过拟合。

5.3 数据增强

AlexNet 在训练时对图片做随机裁剪、水平翻转、颜色扰动等操作,相当于从有限的数据中“变出”更多样本。这同样是为了缓解过拟合。数据增强到今天仍是几乎所有图像任务训练流程的标配。

5.4 与 LeNet-5 的对比

对比维度LeNet-5AlexNet
提出年份19982012
卷积层数3(含 C5)5
全连接层数23
激活函数Sigmoid/TanhReLU
池化方式平均池化最大池化 + 重叠池化
正则化手段无Dropout + 数据增强
总参数量约 6 万约 6000 万
训练硬件单 CPU双 GPU
经典数据集MNISTImageNet

从 LeNet-5 到 AlexNet,网络变得更深、更大,训练技巧更丰富。但核心原则没变:先卷积提特征,再全连接分类。后面出现的 VGG、ResNet 都是在这个范式上继续演进。

6. 环境准备与前置条件

接下来进入代码环节。我们使用 PyTorch 实现并训练 LeNet-5 和 AlexNet 风格的 CNN,在 MNIST 手写数字数据集上验证效果。

6.1 环境要求

建议配置如下,具体版本以你的实际环境为准:

  • Python 3.8 及以上
  • PyTorch 1.10 及以上(CPU 版本即可跑通 MNIST)
  • torchvision(与 PyTorch 版本匹配)
  • matplotlib(用于可视化)
  • numpy

MNIST 数据集非常小,28x28 灰度图,共 10 类数字,6 万张训练图、1 万张测试图。即使只用 CPU,训练几个 epoch 也就几分钟,非常适合入门验证。

6.2 安装依赖

使用 pip 安装:

pip install torch torchvision matplotlib numpy

如果显卡支持 CUDA 且你想用 GPU,请到 PyTorch 官网选择合适的安装命令。这里不做过多展开,因为本文示例在纯 CPU 环境下也能跑通。

验证安装:

python -c "import torch; print(torch.__version__)"

如果输出版本号,说明环境正常。

7. 完整代码实现:从 LeNet-5 到 AlexNet 风格网络

下面我们分四步完成整个项目。

7.1 数据加载与预处理

MNIST 数据集可以通过 torchvision 直接下载。注意:下载需要联网。

# 文件路径:data_loader.py import torch from torch.utils.data import DataLoader from torchvision import datasets, transforms # 数据预处理:转为Tensor,并归一化到 [0, 1] transform = transforms.Compose([ transforms.Resize((32, 32)), # LeNet-5 原始输入是 32x32 transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) # 下载并加载训练集 train_dataset = datasets.MNIST( root="./data", train=True, download=True, transform=transform ) # 下载并加载测试集 test_dataset = datasets.MNIST( root="./data", train=False, download=True, transform=transform ) train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True) test_loader = DataLoader(test_dataset, batch_size=64, shuffle=False) if __name__ == "__main__": # 检查一个 batch 的数据形状 images, labels = next(iter(train_loader)) print(f"训练批次图像形状: {images.shape}") print(f"训练批次标签形状: {labels.shape}")

这里的 transforms.Resize((32, 32)) 会先把 28x28 的 MNIST 图片放大到 32x32,以匹配 LeNet-5 的输入设计。如果你不想 Resize,也可以把网络第一层输入改为 28x28,原理是一样的。

Normalize 使用的均值和标准差是 MNIST 数据集的常用值,作用是把像素分布标准化,加速模型收敛。

7.2 LeNet-5 模型定义

在 PyTorch 中,所有模型都继承自 nn.Module。下面实现一个在 MNIST 上可用的 LeNet-5 变体。

# 文件路径:lenet5.py import torch.nn as nn import torch.nn.functional as F class LeNet5(nn.Module): def __init__(self, num_classes=10): super(LeNet5, self).__init__() # C1: 卷积层,输入1通道,输出6通道,卷积核5x5 self.conv1 = nn.Conv2d(in_channels=1, out_channels=6, kernel_size=5, stride=1, padding=0) # S2: 池化层,2x2最大池化,stride=2 self.pool1 = nn.MaxPool2d(kernel_size=2, stride=2) # C3: 卷积层,输入6通道,输出16通道,卷积核5x5 self.conv2 = nn.Conv2d(in_channels=6, out_channels=16, kernel_size=5, stride=1, padding=0) # S4: 池化层 self.pool2 = nn.MaxPool2d(kernel_size=2, stride=2) # C5: 卷积层,输入16通道,输出120通道,卷积核5x5 self.conv3 = nn.Conv2d(in_channels=16, out_channels=120, kernel_size=5, stride=1, padding=0) # F6: 全连接层 self.fc1 = nn.Linear(120, 84) # OUTPUT: 输出层 self.fc2 = nn.Linear(84, num_classes) def forward(self, x): # 输入 x: [batch_size, 1, 32, 32] x = self.pool1(F.relu(self.conv1(x))) # 32 -> 28 -> 14 x = self.pool2(F.relu(self.conv2(x))) # 14 -> 10 -> 5 x = F.relu(self.conv3(x)) # 5 -> 1 x = x.view(x.size(0), -1) # 展平 x = F.relu(self.fc1(x)) x = self.fc2(x) return x

注意几点:

  1. C5 在原版 LeNet-5 中输出 120x1x1,本质上是把 16x5x5 的特征图直接卷积成一个 120 维向量。这里省略了最后的展平前处理,因为 conv3 后 x.view(x.size(0), -1) 会自动展平。
  2. 激活函数我们使用 ReLU 而不是原版的 Sigmoid。原因前面说过:ReLU 更好训练、收敛更快。在很多 LeNet-5 复现代码中,这种修改是标准做法。
  3. 原版 S2 使用带权重的平均池化,这里用 MaxPool2d 代替。数学上略有差别,但实践上更简洁、效果不差。
  4. 原版 C3 层的 16 个卷积核并不是与输入所有 6 个通道做全连接,而是按一定规则部分连接。这里为了简洁,使用 PyTorch 默认的全连接模式(16 个核每个都与 6 个通道卷积)。

如果你严格按论文复现,需要自定义分组连接,但实际工程中直接使用上述设计已经能达到很好的效果。

7.3 AlexNet 风格简化版模型

完整 AlexNet 需要处理 224x224 输入、参数量达 6000 万,在 MNIST 上不合适。我们实现一个保留 AlexNet 核心思想的简化版本:5 个卷积层 + 3 个全连接层、ReLU、Dropout、最大池化。

# 文件路径:simple_alexnet.py import torch.nn as nn import torch.nn.functional as F class SimpleAlexNet(nn.Module): """ 受 AlexNet 启发的简化 CNN,适配 32x32 灰度图。 核心保留:堆叠卷积、ReLU、Dropout、三层全连接 """ def __init__(self, num_classes=10, dropout_rate=0.5): super(SimpleAlexNet, self).__init__() # 卷积特征提取部分 self.features = nn.Sequential( # 第一段:32x32 -> 16x16 nn.Conv2d(1, 32, kernel_size=3, stride=1, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(kernel_size=2, stride=2), # 第二段:16x16 -> 8x8 nn.Conv2d(32, 64, kernel_size=3, stride=1, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(kernel_size=2, stride=2), # 第三段:8x8 -> 4x4 nn.Conv2d(64, 128, kernel_size=3, stride=1, padding=1), nn.ReLU(inplace=True), # 第四段:4x4 -> 2x2 nn.Conv2d(128, 256, kernel_size=3, stride=1, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(kernel_size=2, stride=2), ) # 分类部分 self.classifier = nn.Sequential( nn.Dropout(p=dropout_rate), nn.Linear(256 * 2 * 2, 256), nn.ReLU(inplace=True), nn.Dropout(p=dropout_rate), nn.Linear(256, num_classes), ) def forward(self, x): x = self.features(x) x = x.view(x.size(0), -1) x = self.classifier(x) return x

这个简化版本保留了 AlexNet 的“深卷积堆叠 + Dropout 正则化”精神。特征图的变化过程是:

32x32x1 -> 16x16x32 -> 8x8x64 -> 4x4x128 -> 2x2x256

最后展平为 256x2x2=1024 维向量,送入全连接层。

7.4 训练与评估函数

有了数据和模型,还需要训练循环。下面的代码可以作为通用模板,适用于上面两个模型。

# 文件路径:train.py import torch import torch.nn as nn import torch.optim as optim from data_loader import train_loader, test_loader from lenet5 import LeNet5 def train_one_epoch(model, loader, criterion, optimizer, device): model.train() total_loss = 0.0 correct = 0 total = 0 for images, labels in loader: images, labels = images.to(device), labels.to(device) # 清零梯度 optimizer.zero_grad() # 前向传播 outputs = model(images) # 计算损失 loss = criterion(outputs, labels) # 反向传播 + 更新参数 loss.backward() optimizer.step() # 统计 total_loss += loss.item() * images.size(0) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() avg_loss = total_loss / total accuracy = correct / total return avg_loss, accuracy def evaluate(model, loader, criterion, device): model.eval() total_loss = 0.0 correct = 0 total = 0 with torch.no_grad(): for images, labels in loader: images, labels = images.to(device), labels.to(device) outputs = model(images) loss = criterion(outputs, labels) total_loss += loss.item() * images.size(0) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() avg_loss = total_loss / total accuracy = correct / total return avg_loss, accuracy def main(): device = torch.device("cuda" if torch.cuda.is_available() else "cpu") print(f"使用设备: {device}") model = LeNet5(num_classes=10).to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=1e-3) num_epochs = 5 for epoch in range(1, num_epochs + 1): train_loss, train_acc = train_one_epoch( model, train_loader, criterion, optimizer, device ) val_loss, val_acc = evaluate(model, test_loader, criterion, device) print( f"Epoch {epoch}/{num_epochs} | " f"Train Loss: {train_loss:.4f}, Train Acc: {train_acc:.4f} | " f"Val Loss: {val_loss:.4f}, Val Acc: {val_acc:.4f}" ) # 保存模型 torch.save(model.state_dict(), "lenet5_mnist.pth") print("模型已保存为 lenet5_mnist.pth") if __name__ == "__main__": main()

关键点:

  • optimizer.zero_grad() 必须在每次反向传播前清零梯度,否则梯度会累加。
  • loss.backward() 计算梯度,optimizer.step() 更新参数。
  • torch.max(outputs, 1) 返回每个样本概率最大的类别索引。
  • model.train() 和 model.eval() 控制 Dropout 等训练专用层的行为。评估时不用 Dropout,所以一定要切换模式。
  • 不同初始化下,5 个 epoch 后 MNIST 测试准确率通常在 98% 以上,这归功于 MNIST 任务本身简单,也说明哪怕基础结构也能达到不错的效果。

如果想切换到 SimpleAlexNet,只需要修改 main 中的模型定义:

from simple_alexnet import SimpleAlexNet model = SimpleAlexNet(num_classes=10).to(device)

其他代码不用改。

8. 运行结果与效果验证

在项目目录下依次执行:

python data_loader.py python train.py

预期输出类似:

使用设备: cpu Epoch 1/5 | Train Loss: 0.2572, Train Acc: 0.9208 | Val Loss: 0.0808, Val Acc: 0.9742 Epoch 2/5 | Train Loss: 0.0768, Train Acc: 0.9769 | Val Loss: 0.0557, Val Acc: 0.9824 Epoch 3/5 | Train Loss: 0.0531, Train Acc: 0.9838 | Val Loss: 0.0437, Val Acc: 0.9846 Epoch 4/5 | Train Loss: 0.0412, Train Acc: 0.9875 | Val Loss: 0.0400, Val Acc: 0.9861 Epoch 5/5 | Train Loss: 0.0337, Train Acc: 0.9898 | Val Loss: 0.0338, Val Acc: 0.9873 模型已保存为 lenet5_mnist.pth

如何判断训练成功?

  1. 训练损失和验证损失都在下降。
  2. 验证准确率稳定在 97% 以上。
  3. 训练集准确率和验证集准确率差距不大,说明没有严重过拟合。

如果损失不降,第一步检查学习率,lr 太大容易震荡,太小收敛极慢。第二步检查数据加载器是否正常,可以用可视化图片的方式确认。

如果想直观看到模型效果,可以加入一段推理代码:

# 文件路径:predict.py import torch import matplotlib.pyplot as plt from torchvision import datasets, transforms from lenet5 import LeNet5 def main(): device = torch.device("cuda" if torch.cuda.is_available() else "cpu") # 加载模型 model = LeNet5(num_classes=10).to(device) model.load_state_dict(torch.load("lenet5_mnist.pth", map_location=device)) model.eval() # 处理单张图 transform = transforms.Compose([ transforms.Resize((32, 32)), transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) test_dataset = datasets.MNIST(root="./data", train=False, download=True, transform=transform) # 取前5张测试图做预测 for i in range(5): image, label = test_dataset[i] with torch.no_grad(): output = model(image.unsqueeze(0).to(device)) _, predicted = torch.max(output, 1) plt.subplot(1, 5, i + 1) plt.imshow(image.squeeze(), cmap="gray") plt.title(f"真实:{label} 预测:{predicted.item()}") plt.axis("off") plt.show() if __name__ == "__main__": main()

运行后可以看到 5 张手写数字图片和对应的预测结果。正常情况预测值和真实值一致。如果全部预测错误,大概率是模型没有正常加载,或者是 transform 和训练时不匹配。

9. 常见问题与排查思路

在训练 CNN 的过程中,下面几个问题是最高频的故障点。

问题现象可能原因排查方式解决方案
报错 “size mismatch”全连接层输入维度与展平后的特征图维度不一致打印每一层输出的 shape,逐步定位用特征图公式手算,或使用 Summary 工具打印模型结构
损失不下降或变成 NaN学习率过大,或数据没有归一化打印 loss 值,检查数据和 label 范围,查看梯度值降低学习率,添加 Normalize 预处理,检查网络是否有除零/开方
验证准确率低但训练准确率高过拟合对比训练集/验证集准确率差值增加 Dropout、数据增强,减少模型参数量,增加训练数据
GPU 上显存不足(OOM)batch size 过大或特征图通道数过多使用 torch.cuda.set_per_process_memory_fraction 限制显存,或观察 nvidia-smi减小 batch size,减小输入图像尺寸,用梯度累积模拟大 batch
训练速度极慢数据加载成为瓶颈,或 CPU 训练大模型查看 CPU 占用率,统计每个 batch 的耗时使用 DataLoader 的 num_workers 参数,或改用 GPU
MNIST 下载失败网络问题或 torchvision 版本过期检查下载地址是否能访问手动下载数据集到指定目录,或者更换下载镜像源
打印预测结果全是一个类别模型没有训练成功,或评估时模式未切换检查 optimizer.step() 是否调用重新训练,确认 model.eval() 在验证代码中已调用

这里特别强调两个最容易在初学阶段被忽视的点:

第一,model.eval()和model.train()不能写错。Dropout 只在训练时随机丢弃神经元,评估时如果忘记切换,模型的预测结果会带有随机性,导致准确率下降。

第二,损失函数 CrossEntropyLoss 内部已经包含 Softmax。你不需要在全连接层后再手动加 Softmax,直接在构造模型时让最后的 Linear 输出原始 logits 即可。如果在最后一个全连接层前自己加了一层 Softmax,训练时的梯度会变得不稳定,还容易让代码和公式对应不上。

10. 最佳实践与工程建议

看完代码能跑通只是第一步。如果要在真实项目中用好 CNN,下面这些经验更值得关注。

10.1 网络设计建议

  • 从简单模型开始,而不是一上来就套 ResNet。MNIST 这种任务用 LeNet-5 就够了,用大模型反而更容易过拟合。先用小模型跑通全流程,再根据瓶颈扩大规模。
  • 优先使用 padding=1、kernel_size=3、stride=1 的卷积组合。一方面保持尺寸不变,卷积分支的设计更简洁;另一方面 3x3 卷积在参数效率和计算效率上都很优。
  • 关于参数初始化:PyTorch 默认初始化通常已经能工作,不熟悉时不要乱改。如果你确实训练困难,再考虑 Xavier / Kaiming 初始化,原因在于激活函数不同,合适的初始化方式也不同。

10.2 训练技巧

  • 固定随机种子,让每次训练结果可复现。
import random import numpy as np import torch def set_seed(seed=42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed)
  • 学习率设置讲究“从大到小”。初期可以用 1e-3,如果损失震荡就调小,如果收敛极慢就尝试调大。更好的做法是使用学习率调度器(StepLR、ReduceLROnPlateau)。
  • 先跑 2-3 个 epoch 验证流程,再跑完整训练。这一步能帮你提前发现数据格式错误、维度问题、硬件资源不足,避免浪费大把时间。

10.3 数据相关建议

  • 不要只做 ToTensor,添加 Normalize 预处理。归一化能让优化过程更稳定。
  • 真实项目中数据增强非常关键,随机裁剪、旋转、翻转、颜色抖动都能大幅度提升泛化能力。
  • 训练集、验证集、测试集要严格分开。不要在测试集上调参,否则你验证的是“记忆”而不是“学习”。

10.4 代码工程化建议

  • 用 argparse 或配置文件管理超参数。网络结构、学习率、batch size、epoch 这些值不要散落在代码各处。
  • 训练时定期保存模型 checkpoint,包含 model_state_dict、optimizer_state_dict、epoch 等信息。这样即使中断也能恢复训练。
  • 用 TensorBoard 或 wandb 记录 loss、accuracy、梯度信息。可视化能极大加速排错效率。
  • 不要在训练脚本里直接改数据文件,数据预处理和模型训练分离,方便复用和测试。

10.5 安全与合规提醒

如果涉及真实业务数据,例如人脸图片、医疗影像、用户图像,务必注意数据隐私和合规授权。不要私自使用未授权的数据训练模型。涉及生产环境部署时,先在小范围测试,验证模型效果和性能后灰度发布,同时保留回滚方案。模型上线后还需持续监控数据分布变化,因为图像数据分布偏移会导致线上准确率下降。

11. 入门后可以继续深入的方向

学完 CNN 的全部组件,跑通两个经典网络模型,你已经掌握了深度学习的核心套路。很多看起来复杂的新模型,本质上就是在“卷积提特征 + 全连接分类”这条主线上做了结构与训练技巧的升级。

下一阶段你可以按顺序深入学习:

  • VGG:观察“小卷积核堆叠”如何取代“大卷积核”。
  • ResNet:理解残差连接如何解决深层网络的退化问题。
  • Vision Transformer (ViT):关注 Transformer 为什么能迁移到图像领域,和 CNN 有什么本质区别。
  • 目标检测(YOLO / Faster R-CNN):掌握分类之外更实用的视觉任务。
  • 模型压缩与部署(ONNX / TensorRT):学完网络之后,需要解决“如何在生产环境中跑得够快”的问题。

真正重要的不是背下更多网络结构,而是建立“看到一个新模型,能手动推演特征图尺寸、参数量、计算量,并能判断它在什么场景下有效”的能力。这条能力线,能让你从看懂教程走向独立做项目。

建议你现在就做一件事:把文中 LeNet-5 的代码抄下来,改成 28x28 输入,去掉 Resize 这个预处理步骤,然后重新计算每一层的特征图尺寸,把模型跑通。做完这一步,你对 CNN 的理解会超过绝大多数只看视频、只知道概念的人。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询